From 28fc344477dabcad35b5cd9ee26ffb6867a8b681 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 01/43] feat(platform-server): add teams grpc client --- package.json | 2 +- packages/platform-server/.env.example | 3 + .../__e2e__/bootstrap.di.test.ts | 1 + .../llmSettings.adminStatus.e2e.test.ts | 3 + .../__e2e__/llmSettings.models.e2e.test.ts | 3 + .../__e2e__/llmProvisioner.bootstrap.test.ts | 1 + .../__tests__/config.service.fromEnv.test.ts | 6 + .../graph.fs.persistence.integration.test.ts | 1 + .../__tests__/helpers/config.ts | 1 + .../src/bootstrap/app.module.ts | 2 + .../src/core/services/config.service.ts | 9 + .../controllers/graphPersist.controller.ts | 150 +--- .../src/graph/graph-api.module.ts | 3 +- packages/platform-server/src/proto/grpc.ts | 227 ++++++ .../platform-server/src/teams/teams.module.ts | 21 + .../src/teams/teamsGrpc.client.ts | 710 ++++++++++++++++++ .../src/teams/teamsGrpc.token.ts | 5 + proto/agynio/api/teams/v1/teams.proto | 421 +++++++++++ proto/buf.yaml | 3 + 19 files changed, 1422 insertions(+), 150 deletions(-) create mode 100644 packages/platform-server/src/teams/teams.module.ts create mode 100644 packages/platform-server/src/teams/teamsGrpc.client.ts create mode 100644 packages/platform-server/src/teams/teamsGrpc.token.ts create mode 100644 proto/agynio/api/teams/v1/teams.proto create mode 100644 proto/buf.yaml diff --git a/package.json b/package.json index f62a26d19..ae40743f1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "pnpm -r --workspace-concurrency=1 run --if-present test", "convert-graphs": "pnpm --filter @agyn/graph-converter exec graph-converter", "postinstall": "pnpm -r --if-present run prepare", - "proto:generate": "buf generate buf.build/agynio/api", + "proto:generate": "buf generate buf.build/agynio/api && buf generate proto", "deps:up:podman": "podman compose up -d" }, "keywords": [], diff --git a/packages/platform-server/.env.example b/packages/platform-server/.env.example index 490eaf020..934be4f5f 100644 --- a/packages/platform-server/.env.example +++ b/packages/platform-server/.env.example @@ -22,6 +22,9 @@ GRAPH_REPO_PATH=./data/graph # Optional logical branch label (metadata only) # GRAPH_BRANCH=main +# Teams gRPC service endpoint +TEAMS_SERVICE_ADDR=teams:9090 + # Optional: GitHub integration (App or PAT). Safe to omit for local dev. # GITHUB_APP_ID= # GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" diff --git a/packages/platform-server/__e2e__/bootstrap.di.test.ts b/packages/platform-server/__e2e__/bootstrap.di.test.ts index e311adcb9..9a91b25d7 100644 --- a/packages/platform-server/__e2e__/bootstrap.di.test.ts +++ b/packages/platform-server/__e2e__/bootstrap.di.test.ts @@ -23,6 +23,7 @@ const REQUIRED_ENV = { LITELLM_BASE_URL: `http://127.0.0.1:${LITELLM_PORT}`, LITELLM_MASTER_KEY: 'sk-test-master-key', AGENTS_DATABASE_URL: 'postgresql://postgres:postgres@127.0.0.1:5432/agents_test?schema=public', + TEAMS_SERVICE_ADDR: 'teams:9090', DOCKER_RUNNER_GRPC_HOST: '127.0.0.1', DOCKER_RUNNER_PORT: '59999', DOCKER_RUNNER_SHARED_SECRET: 'dev-shared-secret', diff --git a/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts b/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts index 66a7adbaf..559d23923 100644 --- a/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts +++ b/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts @@ -15,6 +15,7 @@ describe('LLM settings controller (admin-status endpoint)', () => { agentsDbUrl: process.env.AGENTS_DATABASE_URL, litellmBaseUrl: process.env.LITELLM_BASE_URL, litellmMasterKey: process.env.LITELLM_MASTER_KEY, + teamsServiceAddr: process.env.TEAMS_SERVICE_ADDR, }; beforeAll(async () => { @@ -22,6 +23,7 @@ describe('LLM settings controller (admin-status endpoint)', () => { process.env.AGENTS_DATABASE_URL = 'postgres://localhost:5432/test'; process.env.LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || 'http://127.0.0.1:4000'; process.env.LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY || 'sk-dev-master-1234'; + process.env.TEAMS_SERVICE_ADDR = process.env.TEAMS_SERVICE_ADDR || 'teams:9090'; ConfigService.clearInstanceForTest(); ConfigService.fromEnv(); @@ -42,6 +44,7 @@ describe('LLM settings controller (admin-status endpoint)', () => { process.env.AGENTS_DATABASE_URL = previousEnv.agentsDbUrl; process.env.LITELLM_BASE_URL = previousEnv.litellmBaseUrl; process.env.LITELLM_MASTER_KEY = previousEnv.litellmMasterKey; + process.env.TEAMS_SERVICE_ADDR = previousEnv.teamsServiceAddr; }); it('injects ConfigService and serves admin status when LiteLLM env is configured', async () => { diff --git a/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts b/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts index dd0da50ac..0e2b70188 100644 --- a/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts +++ b/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts @@ -15,6 +15,7 @@ describe('LLM settings controller (models endpoint)', () => { agentsDbUrl: process.env.AGENTS_DATABASE_URL, litellmBaseUrl: process.env.LITELLM_BASE_URL, litellmMasterKey: process.env.LITELLM_MASTER_KEY, + teamsServiceAddr: process.env.TEAMS_SERVICE_ADDR, }; beforeAll(async () => { @@ -22,6 +23,7 @@ describe('LLM settings controller (models endpoint)', () => { process.env.AGENTS_DATABASE_URL = 'postgres://localhost:5432/test'; process.env.LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || 'http://127.0.0.1:4000'; process.env.LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY || 'sk-dev-master-1234'; + process.env.TEAMS_SERVICE_ADDR = process.env.TEAMS_SERVICE_ADDR || 'teams:9090'; ConfigService.clearInstanceForTest(); ConfigService.fromEnv(); @@ -43,6 +45,7 @@ describe('LLM settings controller (models endpoint)', () => { process.env.AGENTS_DATABASE_URL = previousEnv.agentsDbUrl; process.env.LITELLM_BASE_URL = previousEnv.litellmBaseUrl; process.env.LITELLM_MASTER_KEY = previousEnv.litellmMasterKey; + process.env.TEAMS_SERVICE_ADDR = previousEnv.teamsServiceAddr; }); it('returns model list via injected service', async () => { diff --git a/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts b/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts index e29160bd6..76fd8a4b0 100644 --- a/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts +++ b/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts @@ -18,6 +18,7 @@ describe('LiteLLMProvisioner bootstrap (DI smoke)', () => { LITELLM_BASE_URL: 'http://127.0.0.1:4000', LITELLM_MASTER_KEY: 'sk-test', AGENTS_DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/agents_test', + TEAMS_SERVICE_ADDR: 'teams:9090', }; beforeEach(() => { diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 3bfdd43a8..1e571b974 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -15,6 +15,7 @@ const trackedEnvKeys = [ 'AGENTS_ENV', 'AGENTS_DEPLOYMENT', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; @@ -45,6 +46,7 @@ describe('ConfigService.fromEnv', () => { process.env.LITELLM_KEY_DURATION = ' 15m '; process.env.LITELLM_MODELS = 'gpt-5o, claude-4 '; process.env.AGENTS_DATABASE_URL = 'postgresql://agents:agents@localhost:5443/agents'; + process.env.TEAMS_SERVICE_ADDR = 'teams:9090'; const config = ConfigService.fromEnv(); @@ -64,6 +66,7 @@ describe('ConfigService.fromEnv', () => { process.env.AGENTS_DATABASE_URL = 'postgresql://agents:agents@localhost:5443/agents'; process.env.AGENTS_ENV = ' staging '; process.env.HOSTNAME = 'web-1'; + process.env.TEAMS_SERVICE_ADDR = 'teams:9090'; const config = ConfigService.fromEnv(); @@ -76,6 +79,7 @@ describe('ConfigService.fromEnv', () => { process.env.LITELLM_BASE_URL = 'http://litellm.internal:4000'; process.env.LITELLM_MASTER_KEY = 'sk-master'; process.env.AGENTS_DATABASE_URL = 'postgresql://agents:agents@localhost:5443/agents'; + process.env.TEAMS_SERVICE_ADDR = 'teams:9090'; expect(() => ConfigService.fromEnv()).toThrowError( 'LLM_PROVIDER must be either "litellm" or "openai", received "anthropic"', @@ -89,6 +93,7 @@ describe('ConfigService.fromEnv', () => { process.env.AGENTS_DATABASE_URL = 'postgresql://agents:agents@localhost:5443/agents'; process.env.OPENAI_API_KEY = 'sk-openai'; process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'; + process.env.TEAMS_SERVICE_ADDR = 'teams:9090'; const config = ConfigService.fromEnv(); @@ -104,6 +109,7 @@ describe('ConfigService.fromEnv', () => { process.env.AGENTS_DATABASE_URL = 'postgresql://agents:agents@localhost:5443/agents'; process.env.OPENAI_API_KEY = 'sk-openai-lite'; process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'; + process.env.TEAMS_SERVICE_ADDR = 'teams:9090'; const config = ConfigService.fromEnv(); diff --git a/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts b/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts index 9ccfc5e25..50eca1674 100644 --- a/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts +++ b/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts @@ -30,6 +30,7 @@ const baseConfigEnv = { litellmKeyAlias: 'agents/test/fs', litellmKeyDuration: '30d', litellmModels: ['all-team-models'], + teamsServiceAddr: 'teams:9090', dockerMirrorUrl: 'http://registry-mirror:5000', nixAllowedChannels: 'nixpkgs-unstable', ...runnerConfigDefaults, diff --git a/packages/platform-server/__tests__/helpers/config.ts b/packages/platform-server/__tests__/helpers/config.ts index 291d2dc68..d60dac40d 100644 --- a/packages/platform-server/__tests__/helpers/config.ts +++ b/packages/platform-server/__tests__/helpers/config.ts @@ -7,6 +7,7 @@ export const runnerConfigDefaults = { litellmKeyAlias: 'agents/test/local', litellmKeyDuration: '30d', litellmModels: ['all-team-models'], + teamsServiceAddr: 'teams:9090', } as const; const defaultConfigInput = { diff --git a/packages/platform-server/src/bootstrap/app.module.ts b/packages/platform-server/src/bootstrap/app.module.ts index 5a7f243b4..eb611bcf0 100644 --- a/packages/platform-server/src/bootstrap/app.module.ts +++ b/packages/platform-server/src/bootstrap/app.module.ts @@ -11,6 +11,7 @@ import { LLMSettingsModule } from '../settings/llm/llmSettings.module'; import { LLMModule } from '../llm/llm.module'; import { LLMProvisioner } from '../llm/provisioners/llm.provisioner'; import { OnboardingModule } from '../onboarding/onboarding.module'; +import { TeamsModule } from '../teams/teams.module'; import { UserProfileModule } from '../user-profile/user-profile.module'; type PinoLoggerModule = { @@ -60,6 +61,7 @@ const createLoggerModule = (): DynamicModule => { GraphApiModule, NodesModule, GatewayModule, + TeamsModule, UserProfileModule, OnboardingModule, LLMSettingsModule, diff --git a/packages/platform-server/src/core/services/config.service.ts b/packages/platform-server/src/core/services/config.service.ts index eca3946e8..087efc058 100644 --- a/packages/platform-server/src/core/services/config.service.ts +++ b/packages/platform-server/src/core/services/config.service.ts @@ -54,6 +54,10 @@ export const configSchema = z.object({ const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : 5000; }), + teamsServiceAddr: z + .string() + .min(1, 'TEAMS_SERVICE_ADDR is required') + .transform((value) => value.trim()), // Optional Vault flags (disabled by default) vaultEnabled: z .union([z.boolean(), z.string()]) @@ -366,6 +370,10 @@ export class ConfigService implements Config { return this.params.graphLockTimeoutMs; } + get teamsServiceAddr(): string { + return this.params.teamsServiceAddr; + } + // Vault getters (optional) get vaultEnabled(): boolean { return !!this.params.vaultEnabled; @@ -576,6 +584,7 @@ export class ConfigService implements Config { graphAuthorName: process.env.GRAPH_AUTHOR_NAME, graphAuthorEmail: process.env.GRAPH_AUTHOR_EMAIL, graphLockTimeoutMs: process.env.GRAPH_LOCK_TIMEOUT_MS, + teamsServiceAddr: process.env.TEAMS_SERVICE_ADDR, vaultEnabled: process.env.VAULT_ENABLED, vaultAddr: process.env.VAULT_ADDR, vaultToken: process.env.VAULT_TOKEN, diff --git a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts b/packages/platform-server/src/graph/controllers/graphPersist.controller.ts index 4b0bddbc1..f8d81f7e9 100644 --- a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts +++ b/packages/platform-server/src/graph/controllers/graphPersist.controller.ts @@ -1,44 +1,9 @@ -import { Controller, Get, Post, Headers, Body, HttpCode, HttpException, HttpStatus, Inject, Logger } from '@nestjs/common'; -// import type { FastifyReply } from 'fastify'; -import { TemplateRegistry } from '../../graph-core/templateRegistry'; -import { LiveGraphRuntime } from '../../graph-core/liveGraph.manager'; -import { GraphRepository, type GraphAuthor } from '../graph.repository'; -import { GraphError } from '../types'; -import type { - GraphDefinition, - PersistedGraphUpsertRequest, - PersistedGraphUpsertResponse, -} from '../../shared/types/graph.types'; -import { z } from 'zod'; -import { GraphErrorCode } from '../errors'; -import { GraphGuard } from '../graph.guard'; - -// Helper to convert persisted graph to runtime GraphDefinition (mirrors src/index.ts) -const toRuntimeGraph = (saved: { nodes: Array<{ id: string; template: string; config?: Record; state?: Record }>; edges: Array<{ source: string; sourceHandle: string; target: string; targetHandle: string }> }): GraphDefinition => { - return { - nodes: saved.nodes.map((n) => ({ - id: n.id, - data: { template: n.template, config: n.config, state: n.state }, - })), - edges: saved.edges.map((e) => ({ - source: e.source, - sourceHandle: e.sourceHandle, - target: e.target, - targetHandle: e.targetHandle, - })), - } as GraphDefinition; -}; +import { Controller, Get, Inject } from '@nestjs/common'; +import { GraphRepository } from '../graph.repository'; @Controller('api') export class GraphPersistController { - private readonly logger = new Logger(GraphPersistController.name); - - constructor( - @Inject(TemplateRegistry) private readonly templates: TemplateRegistry, - @Inject(LiveGraphRuntime) private readonly runtime: LiveGraphRuntime, - @Inject(GraphRepository) private readonly graphs: GraphRepository, - @Inject(GraphGuard) private readonly guard: GraphGuard, - ) {} + constructor(@Inject(GraphRepository) private readonly graphs: GraphRepository) {} @Get('graph') async getGraph(): Promise<{ name: string; version: number; updatedAt: string; nodes: { id: string; template: string; config?: Record; state?: Record; position?: { x: number; y: number } }[]; edges: { id?: string; source: string; sourceHandle: string; target: string; targetHandle: string }[]; variables?: Array<{ key: string; value: string }> }> { @@ -49,113 +14,4 @@ export class GraphPersistController { } return graph; } - -@Post('graph') -@HttpCode(200) -async upsertGraph( - @Body() body: unknown, - @Headers() headers: Record, -): Promise { - try { - const parsedResult = UpsertSchema.safeParse(body); - if (!parsedResult.success) { - throw new HttpException({ error: 'BAD_SCHEMA', current: parsedResult.error.format() }, HttpStatus.BAD_REQUEST); - } - const parsed = parsedResult.data as PersistedGraphUpsertRequest; - parsed.name = parsed.name || 'main'; - // Resolve author from headers (support legacy keys) - const author: GraphAuthor = { - name: (headers['x-graph-author-name'] || headers['x-author-name']) as string | undefined, - email: (headers['x-graph-author-email'] || headers['x-author-email']) as string | undefined, - }; - // Capture previous graph (for change detection / events) - const before = await this.graphs.get(parsed.name); - - // Guard against unsafe MCP command mutation - try { - this.guard.enforceMcpCommandMutationGuard(before, parsed, this.runtime); - } catch (e: unknown) { - if (e instanceof GraphError && e?.code === GraphErrorCode.McpCommandMutationForbidden) { - // 409 with error code body - const err = { error: GraphErrorCode.McpCommandMutationForbidden } as const; - throw new HttpException(err, HttpStatus.CONFLICT); - } - throw e; - } - - const saved = await this.graphs.upsert(parsed, author); - try { - await this.runtime.apply(toRuntimeGraph(saved)); - } catch { - this.logger.debug('Failed to apply updated graph to runtime; rolling back persistence'); - } - - // Emit node_config events for any node whose static config changed - if (before) this.emitNodeConfigChanges(before, saved); - return saved; - } catch (e: unknown) { - // Map known repository errors to status codes and bodies - const err = e as { code?: string; current?: unknown; message?: string }; - if (err?.code === 'VERSION_CONFLICT') { - throw new HttpException({ error: 'VERSION_CONFLICT', current: err.current }, HttpStatus.CONFLICT); - } - if (err?.code === 'LOCK_TIMEOUT') { - throw new HttpException({ error: 'LOCK_TIMEOUT' }, HttpStatus.CONFLICT); - } - if (err?.code === 'PERSIST_FAILED') { - throw new HttpException({ error: 'PERSIST_FAILED' }, HttpStatus.INTERNAL_SERVER_ERROR); - } - const msg = e instanceof Error ? e.message : String(e); - throw new HttpException({ error: msg || 'Bad Request' }, HttpStatus.BAD_REQUEST); - } - } - - private emitNodeConfigChanges( - before: { nodes: Array<{ id: string; config?: Record }> }, - saved: { nodes: Array<{ id: string; config?: Record }>; version: number }, - ): void { - const beforeStatic = new Map(before.nodes.map((n) => [n.id, JSON.stringify(n.config || {})])); - for (const n of saved.nodes) { - const prevS = beforeStatic.get(n.id); - const currS = JSON.stringify(n.config || {}); - if (prevS !== currS) { - // Socket.io Gateway not wired in Nest yet; log and TODO - this.logger.log( - `node_config changed [TODO: emit via gateway] ${JSON.stringify({ nodeId: n.id, version: saved.version })}`, - ); - } - } - } } -// Zod schema for upsert body (controller boundary schema) -const UpsertSchema = z - .object({ - name: z.string().min(1), - version: z.number().int().nonnegative().optional(), - nodes: z - .array( - z.object({ - id: z.string().min(1), - template: z.string().min(1), - config: z.record(z.string(), z.unknown()).optional(), - state: z.record(z.string(), z.unknown()).optional(), - position: z.object({ x: z.number(), y: z.number() }).optional(), - }), - ) - .max(1000), - edges: z - .array( - z.object({ - id: z.string().optional(), - source: z.string().min(1), - sourceHandle: z.string().min(1), - target: z.string().min(1), - targetHandle: z.string().min(1), - }), - ) - .max(2000), - variables: z - .array(z.object({ key: z.string().min(1), value: z.string().min(1) })) - .optional(), - }) - .strict(); diff --git a/packages/platform-server/src/graph/graph-api.module.ts b/packages/platform-server/src/graph/graph-api.module.ts index 7f8714e21..e418a0022 100644 --- a/packages/platform-server/src/graph/graph-api.module.ts +++ b/packages/platform-server/src/graph/graph-api.module.ts @@ -7,7 +7,6 @@ import { GraphPersistController } from './controllers/graphPersist.controller'; import { GraphVariablesController } from './controllers/graphVariables.controller'; import { MemoryController } from './controllers/memory.controller'; import { RunsController } from './controllers/runs.controller'; -import { GraphGuard } from './graph.guard'; import { NodeStateService } from './nodeState.service'; import { GraphVariablesService } from './services/graphVariables.service'; import { GraphDomainModule } from '../graph-domain/graph-domain.module'; @@ -28,7 +27,7 @@ import { GraphCoreModule } from '../graph-core/graph-core.module'; AgentsRemindersController, RemindersController, ], - providers: [GraphGuard, NodeStateService, GraphVariablesService], + providers: [NodeStateService, GraphVariablesService], exports: [GraphCoreModule, NodeStateService, GraphVariablesService], }) export class GraphApiModule {} diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts index ca2e4221c..5a3d37cdb 100644 --- a/packages/platform-server/src/proto/grpc.ts +++ b/packages/platform-server/src/proto/grpc.ts @@ -2,6 +2,7 @@ import { makeGenericClientConstructor } from '@grpc/grpc-js'; import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; import { toBinary, fromBinary } from '@bufbuild/protobuf'; import type { DescMessage } from '@bufbuild/protobuf'; +import { EmptySchema } from '@bufbuild/protobuf/wkt'; import { CancelExecutionRequestSchema, CancelExecutionResponseSchema, @@ -34,6 +35,48 @@ import { TouchWorkloadRequestSchema, TouchWorkloadResponseSchema, } from './gen/agynio/api/runner/v1/runner_pb.js'; +import { + AgentCreateRequestSchema, + AgentSchema, + AgentUpdateRequestSchema, + AttachmentCreateRequestSchema, + AttachmentSchema, + DeleteAgentRequestSchema, + DeleteAttachmentRequestSchema, + DeleteMcpServerRequestSchema, + DeleteMemoryBucketRequestSchema, + DeleteToolRequestSchema, + DeleteWorkspaceConfigurationRequestSchema, + GetAgentRequestSchema, + GetMcpServerRequestSchema, + GetMemoryBucketRequestSchema, + GetToolRequestSchema, + GetWorkspaceConfigurationRequestSchema, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + McpServerCreateRequestSchema, + McpServerSchema, + McpServerUpdateRequestSchema, + MemoryBucketCreateRequestSchema, + MemoryBucketSchema, + MemoryBucketUpdateRequestSchema, + PaginatedAgentsSchema, + PaginatedAttachmentsSchema, + PaginatedMcpServersSchema, + PaginatedMemoryBucketsSchema, + PaginatedToolsSchema, + PaginatedWorkspaceConfigurationsSchema, + ToolCreateRequestSchema, + ToolSchema, + ToolUpdateRequestSchema, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationSchema, + WorkspaceConfigurationUpdateRequestSchema, +} from './gen/agynio/api/teams/v1/teams_pb.js'; const unaryDefinition = ( path: string, @@ -180,3 +223,187 @@ export const RunnerServiceGrpcClient = makeGenericClientConstructor( ); export type RunnerServiceGrpcClientInstance = InstanceType; + +export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; +export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; +export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; +export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; +export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; +export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; +export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; +export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; +export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; +export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; +export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; +export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; +export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; +export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; +export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; +export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = + '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; +export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; +export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; +export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; +export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; +export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; +export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; +export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; +export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; +export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; +export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; +export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; +export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; + +export const teamsServiceGrpcDefinition: ServiceDefinition = { + listAgents: unaryDefinition( + TEAMS_SERVICE_LIST_AGENTS_PATH, + ListAgentsRequestSchema, + PaginatedAgentsSchema, + ), + createAgent: unaryDefinition( + TEAMS_SERVICE_CREATE_AGENT_PATH, + AgentCreateRequestSchema, + AgentSchema, + ), + getAgent: unaryDefinition( + TEAMS_SERVICE_GET_AGENT_PATH, + GetAgentRequestSchema, + AgentSchema, + ), + updateAgent: unaryDefinition( + TEAMS_SERVICE_UPDATE_AGENT_PATH, + AgentUpdateRequestSchema, + AgentSchema, + ), + deleteAgent: unaryDefinition( + TEAMS_SERVICE_DELETE_AGENT_PATH, + DeleteAgentRequestSchema, + EmptySchema, + ), + listTools: unaryDefinition( + TEAMS_SERVICE_LIST_TOOLS_PATH, + ListToolsRequestSchema, + PaginatedToolsSchema, + ), + createTool: unaryDefinition( + TEAMS_SERVICE_CREATE_TOOL_PATH, + ToolCreateRequestSchema, + ToolSchema, + ), + getTool: unaryDefinition( + TEAMS_SERVICE_GET_TOOL_PATH, + GetToolRequestSchema, + ToolSchema, + ), + updateTool: unaryDefinition( + TEAMS_SERVICE_UPDATE_TOOL_PATH, + ToolUpdateRequestSchema, + ToolSchema, + ), + deleteTool: unaryDefinition( + TEAMS_SERVICE_DELETE_TOOL_PATH, + DeleteToolRequestSchema, + EmptySchema, + ), + listMcpServers: unaryDefinition( + TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + ListMcpServersRequestSchema, + PaginatedMcpServersSchema, + ), + createMcpServer: unaryDefinition( + TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + McpServerCreateRequestSchema, + McpServerSchema, + ), + getMcpServer: unaryDefinition( + TEAMS_SERVICE_GET_MCP_SERVER_PATH, + GetMcpServerRequestSchema, + McpServerSchema, + ), + updateMcpServer: unaryDefinition( + TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + McpServerUpdateRequestSchema, + McpServerSchema, + ), + deleteMcpServer: unaryDefinition( + TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + DeleteMcpServerRequestSchema, + EmptySchema, + ), + listWorkspaceConfigurations: unaryDefinition( + TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + ListWorkspaceConfigurationsRequestSchema, + PaginatedWorkspaceConfigurationsSchema, + ), + createWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationSchema, + ), + getWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + GetWorkspaceConfigurationRequestSchema, + WorkspaceConfigurationSchema, + ), + updateWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, + WorkspaceConfigurationUpdateRequestSchema, + WorkspaceConfigurationSchema, + ), + deleteWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + DeleteWorkspaceConfigurationRequestSchema, + EmptySchema, + ), + listMemoryBuckets: unaryDefinition( + TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + ListMemoryBucketsRequestSchema, + PaginatedMemoryBucketsSchema, + ), + createMemoryBucket: unaryDefinition( + TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + MemoryBucketCreateRequestSchema, + MemoryBucketSchema, + ), + getMemoryBucket: unaryDefinition( + TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + GetMemoryBucketRequestSchema, + MemoryBucketSchema, + ), + updateMemoryBucket: unaryDefinition( + TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + MemoryBucketUpdateRequestSchema, + MemoryBucketSchema, + ), + deleteMemoryBucket: unaryDefinition( + TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + DeleteMemoryBucketRequestSchema, + EmptySchema, + ), + listAttachments: unaryDefinition( + TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + ListAttachmentsRequestSchema, + PaginatedAttachmentsSchema, + ), + createAttachment: unaryDefinition( + TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + AttachmentCreateRequestSchema, + AttachmentSchema, + ), + deleteAttachment: unaryDefinition( + TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + DeleteAttachmentRequestSchema, + EmptySchema, + ), +}; + +export const TeamsServiceGrpcClient = makeGenericClientConstructor( + teamsServiceGrpcDefinition, + 'agynio.api.teams.v1.TeamsService', +); + +export type TeamsServiceGrpcClientInstance = InstanceType; diff --git a/packages/platform-server/src/teams/teams.module.ts b/packages/platform-server/src/teams/teams.module.ts new file mode 100644 index 000000000..daf266d7e --- /dev/null +++ b/packages/platform-server/src/teams/teams.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { CoreModule } from '../core/core.module'; +import { ConfigService } from '../core/services/config.service'; +import { TeamsGrpcClient } from './teamsGrpc.client'; +import { TEAMS_GRPC_CLIENT } from './teamsGrpc.token'; + +@Module({ + imports: [CoreModule], + providers: [ + { + provide: TEAMS_GRPC_CLIENT, + useFactory: (config: ConfigService) => + new TeamsGrpcClient({ + address: config.teamsServiceAddr, + }), + inject: [ConfigService], + }, + ], + exports: [TEAMS_GRPC_CLIENT], +}) +export class TeamsModule {} diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts new file mode 100644 index 000000000..197b54e31 --- /dev/null +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -0,0 +1,710 @@ +import { create } from '@bufbuild/protobuf'; +import type { Empty } from '@bufbuild/protobuf/wkt'; +import { HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { credentials, Metadata, status, type CallOptions, type ServiceError } from '@grpc/grpc-js'; +import { + AgentCreateRequestSchema, + AgentUpdateRequestSchema, + AttachmentCreateRequestSchema, + DeleteAgentRequestSchema, + DeleteAttachmentRequestSchema, + DeleteMcpServerRequestSchema, + DeleteMemoryBucketRequestSchema, + DeleteToolRequestSchema, + DeleteWorkspaceConfigurationRequestSchema, + GetAgentRequestSchema, + GetMcpServerRequestSchema, + GetMemoryBucketRequestSchema, + GetToolRequestSchema, + GetWorkspaceConfigurationRequestSchema, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + McpServerCreateRequestSchema, + McpServerUpdateRequestSchema, + MemoryBucketCreateRequestSchema, + MemoryBucketUpdateRequestSchema, + ToolCreateRequestSchema, + ToolUpdateRequestSchema, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationUpdateRequestSchema, +} from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; +import type { + Agent, + AgentCreateRequest, + AgentUpdateRequest, + Attachment, + AttachmentCreateRequest, + DeleteAgentRequest, + DeleteAttachmentRequest, + DeleteMcpServerRequest, + DeleteMemoryBucketRequest, + DeleteToolRequest, + DeleteWorkspaceConfigurationRequest, + GetAgentRequest, + GetMcpServerRequest, + GetMemoryBucketRequest, + GetToolRequest, + GetWorkspaceConfigurationRequest, + ListAgentsRequest, + ListAttachmentsRequest, + ListMcpServersRequest, + ListMemoryBucketsRequest, + ListToolsRequest, + ListWorkspaceConfigurationsRequest, + McpServer, + McpServerCreateRequest, + McpServerUpdateRequest, + MemoryBucket, + MemoryBucketCreateRequest, + MemoryBucketUpdateRequest, + PaginatedAgents, + PaginatedAttachments, + PaginatedMcpServers, + PaginatedMemoryBuckets, + PaginatedTools, + PaginatedWorkspaceConfigurations, + Tool, + ToolCreateRequest, + ToolUpdateRequest, + WorkspaceConfiguration, + WorkspaceConfigurationCreateRequest, + WorkspaceConfigurationUpdateRequest, +} from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; +import { + TeamsServiceGrpcClient, + type TeamsServiceGrpcClientInstance, + TEAMS_SERVICE_CREATE_AGENT_PATH, + TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + TEAMS_SERVICE_CREATE_TOOL_PATH, + TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + TEAMS_SERVICE_DELETE_AGENT_PATH, + TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + TEAMS_SERVICE_DELETE_TOOL_PATH, + TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + TEAMS_SERVICE_GET_AGENT_PATH, + TEAMS_SERVICE_GET_MCP_SERVER_PATH, + TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + TEAMS_SERVICE_GET_TOOL_PATH, + TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + TEAMS_SERVICE_LIST_AGENTS_PATH, + TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + TEAMS_SERVICE_LIST_TOOLS_PATH, + TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + TEAMS_SERVICE_UPDATE_AGENT_PATH, + TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + TEAMS_SERVICE_UPDATE_TOOL_PATH, + TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, +} from '../proto/grpc.js'; + +type TeamsGrpcClientConfig = { + address: string; + requestTimeoutMs?: number; +}; + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_ERROR_MESSAGE = 'Teams service request failed'; + +export class TeamsGrpcRequestError extends HttpException { + constructor( + statusCode: number, + readonly grpcCode: status, + readonly errorCode: string, + message: string, + ) { + super({ error: errorCode, message, grpcCode }, statusCode); + this.name = 'TeamsGrpcRequestError'; + } +} + +export class TeamsGrpcClient { + private readonly client: TeamsServiceGrpcClientInstance; + private readonly requestTimeoutMs: number; + private readonly endpoint: string; + private readonly logger = new Logger(TeamsGrpcClient.name); + + constructor(config: TeamsGrpcClientConfig) { + const address = config.address?.trim(); + if (!address) { + throw new Error('TeamsGrpcClient requires a valid address'); + } + this.endpoint = address; + this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.client = new TeamsServiceGrpcClient(address, credentials.createInsecure()); + } + + getEndpoint(): string { + return this.endpoint; + } + + async listAgents(request: ListAgentsRequest): Promise { + const message = create(ListAgentsRequestSchema, request); + return this.unary( + TEAMS_SERVICE_LIST_AGENTS_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.listAgents(req, metadata, options, callback); + return; + } + this.client.listAgents(req, metadata, callback); + }, + ); + } + + async createAgent(request: AgentCreateRequest): Promise { + const message = create(AgentCreateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_CREATE_AGENT_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.createAgent(req, metadata, options, callback); + return; + } + this.client.createAgent(req, metadata, callback); + }, + ); + } + + async getAgent(request: GetAgentRequest): Promise { + const message = create(GetAgentRequestSchema, request); + return this.unary( + TEAMS_SERVICE_GET_AGENT_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.getAgent(req, metadata, options, callback); + return; + } + this.client.getAgent(req, metadata, callback); + }, + ); + } + + async updateAgent(request: AgentUpdateRequest): Promise { + const message = create(AgentUpdateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_UPDATE_AGENT_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.updateAgent(req, metadata, options, callback); + return; + } + this.client.updateAgent(req, metadata, callback); + }, + ); + } + + async deleteAgent(request: DeleteAgentRequest): Promise { + const message = create(DeleteAgentRequestSchema, request); + await this.unary( + TEAMS_SERVICE_DELETE_AGENT_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.deleteAgent(req, metadata, options, callback); + return; + } + this.client.deleteAgent(req, metadata, callback); + }, + ); + } + + async listTools(request: ListToolsRequest): Promise { + const message = create(ListToolsRequestSchema, request); + return this.unary( + TEAMS_SERVICE_LIST_TOOLS_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.listTools(req, metadata, options, callback); + return; + } + this.client.listTools(req, metadata, callback); + }, + ); + } + + async createTool(request: ToolCreateRequest): Promise { + const message = create(ToolCreateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_CREATE_TOOL_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.createTool(req, metadata, options, callback); + return; + } + this.client.createTool(req, metadata, callback); + }, + ); + } + + async getTool(request: GetToolRequest): Promise { + const message = create(GetToolRequestSchema, request); + return this.unary( + TEAMS_SERVICE_GET_TOOL_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.getTool(req, metadata, options, callback); + return; + } + this.client.getTool(req, metadata, callback); + }, + ); + } + + async updateTool(request: ToolUpdateRequest): Promise { + const message = create(ToolUpdateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_UPDATE_TOOL_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.updateTool(req, metadata, options, callback); + return; + } + this.client.updateTool(req, metadata, callback); + }, + ); + } + + async deleteTool(request: DeleteToolRequest): Promise { + const message = create(DeleteToolRequestSchema, request); + await this.unary( + TEAMS_SERVICE_DELETE_TOOL_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.deleteTool(req, metadata, options, callback); + return; + } + this.client.deleteTool(req, metadata, callback); + }, + ); + } + + async listMcpServers(request: ListMcpServersRequest): Promise { + const message = create(ListMcpServersRequestSchema, request); + return this.unary( + TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.listMcpServers(req, metadata, options, callback); + return; + } + this.client.listMcpServers(req, metadata, callback); + }, + ); + } + + async createMcpServer(request: McpServerCreateRequest): Promise { + const message = create(McpServerCreateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.createMcpServer(req, metadata, options, callback); + return; + } + this.client.createMcpServer(req, metadata, callback); + }, + ); + } + + async getMcpServer(request: GetMcpServerRequest): Promise { + const message = create(GetMcpServerRequestSchema, request); + return this.unary( + TEAMS_SERVICE_GET_MCP_SERVER_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.getMcpServer(req, metadata, options, callback); + return; + } + this.client.getMcpServer(req, metadata, callback); + }, + ); + } + + async updateMcpServer(request: McpServerUpdateRequest): Promise { + const message = create(McpServerUpdateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.updateMcpServer(req, metadata, options, callback); + return; + } + this.client.updateMcpServer(req, metadata, callback); + }, + ); + } + + async deleteMcpServer(request: DeleteMcpServerRequest): Promise { + const message = create(DeleteMcpServerRequestSchema, request); + await this.unary( + TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.deleteMcpServer(req, metadata, options, callback); + return; + } + this.client.deleteMcpServer(req, metadata, callback); + }, + ); + } + + async listWorkspaceConfigurations( + request: ListWorkspaceConfigurationsRequest, + ): Promise { + const message = create(ListWorkspaceConfigurationsRequestSchema, request); + return this.unary( + TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.listWorkspaceConfigurations(req, metadata, options, callback); + return; + } + this.client.listWorkspaceConfigurations(req, metadata, callback); + }, + ); + } + + async createWorkspaceConfiguration( + request: WorkspaceConfigurationCreateRequest, + ): Promise { + const message = create(WorkspaceConfigurationCreateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.createWorkspaceConfiguration(req, metadata, options, callback); + return; + } + this.client.createWorkspaceConfiguration(req, metadata, callback); + }, + ); + } + + async getWorkspaceConfiguration( + request: GetWorkspaceConfigurationRequest, + ): Promise { + const message = create(GetWorkspaceConfigurationRequestSchema, request); + return this.unary( + TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.getWorkspaceConfiguration(req, metadata, options, callback); + return; + } + this.client.getWorkspaceConfiguration(req, metadata, callback); + }, + ); + } + + async updateWorkspaceConfiguration( + request: WorkspaceConfigurationUpdateRequest, + ): Promise { + const message = create(WorkspaceConfigurationUpdateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.updateWorkspaceConfiguration(req, metadata, options, callback); + return; + } + this.client.updateWorkspaceConfiguration(req, metadata, callback); + }, + ); + } + + async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { + const message = create(DeleteWorkspaceConfigurationRequestSchema, request); + await this.unary( + TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.deleteWorkspaceConfiguration(req, metadata, options, callback); + return; + } + this.client.deleteWorkspaceConfiguration(req, metadata, callback); + }, + ); + } + + async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { + const message = create(ListMemoryBucketsRequestSchema, request); + return this.unary( + TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.listMemoryBuckets(req, metadata, options, callback); + return; + } + this.client.listMemoryBuckets(req, metadata, callback); + }, + ); + } + + async createMemoryBucket(request: MemoryBucketCreateRequest): Promise { + const message = create(MemoryBucketCreateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.createMemoryBucket(req, metadata, options, callback); + return; + } + this.client.createMemoryBucket(req, metadata, callback); + }, + ); + } + + async getMemoryBucket(request: GetMemoryBucketRequest): Promise { + const message = create(GetMemoryBucketRequestSchema, request); + return this.unary( + TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.getMemoryBucket(req, metadata, options, callback); + return; + } + this.client.getMemoryBucket(req, metadata, callback); + }, + ); + } + + async updateMemoryBucket(request: MemoryBucketUpdateRequest): Promise { + const message = create(MemoryBucketUpdateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.updateMemoryBucket(req, metadata, options, callback); + return; + } + this.client.updateMemoryBucket(req, metadata, callback); + }, + ); + } + + async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { + const message = create(DeleteMemoryBucketRequestSchema, request); + await this.unary( + TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.deleteMemoryBucket(req, metadata, options, callback); + return; + } + this.client.deleteMemoryBucket(req, metadata, callback); + }, + ); + } + + async listAttachments(request: ListAttachmentsRequest): Promise { + const message = create(ListAttachmentsRequestSchema, request); + return this.unary( + TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.listAttachments(req, metadata, options, callback); + return; + } + this.client.listAttachments(req, metadata, callback); + }, + ); + } + + async createAttachment(request: AttachmentCreateRequest): Promise { + const message = create(AttachmentCreateRequestSchema, request); + return this.unary( + TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.createAttachment(req, metadata, options, callback); + return; + } + this.client.createAttachment(req, metadata, callback); + }, + ); + } + + async deleteAttachment(request: DeleteAttachmentRequest): Promise { + const message = create(DeleteAttachmentRequestSchema, request); + await this.unary( + TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + message, + (req, metadata, options, callback) => { + if (options) { + this.client.deleteAttachment(req, metadata, options, callback); + return; + } + this.client.deleteAttachment(req, metadata, callback); + }, + ); + } + + private unary( + path: string, + request: Request, + invoke: ( + request: Request, + metadata: Metadata, + options: CallOptions | undefined, + callback: (err: ServiceError | null, response?: Response) => void, + ) => void, + timeoutMs?: number, + ): Promise { + const metadata = this.createMetadata(); + const callOptions = this.buildCallOptions(timeoutMs); + return new Promise((resolve, reject) => { + const callback = (err: ServiceError | null, response?: Response) => { + if (err) { + reject(this.translateServiceError(err, { path })); + return; + } + resolve(response as Response); + }; + invoke(request, metadata, callOptions, callback); + }); + } + + private buildCallOptions(timeoutMs?: number): CallOptions | undefined { + const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : this.requestTimeoutMs; + if (!timeout || timeout <= 0) return undefined; + return { deadline: new Date(Date.now() + timeout) }; + } + + private createMetadata(): Metadata { + return new Metadata(); + } + + private translateServiceError(error: ServiceError, context?: { path?: string }): HttpException { + const grpcCode = typeof error.code === 'number' ? error.code : status.UNKNOWN; + const statusName = (status as unknown as Record)[grpcCode] ?? 'UNKNOWN'; + const message = this.extractServiceErrorMessage(error); + const httpStatus = this.grpcStatusToHttpStatus(grpcCode); + const errorCode = this.grpcStatusToErrorCode(grpcCode); + const path = context?.path ?? 'unknown'; + if (grpcCode === status.UNIMPLEMENTED) { + this.logger.error('Teams gRPC call returned UNIMPLEMENTED', { + path, + grpcStatus: statusName, + grpcCode, + message, + }); + } else { + this.logger.warn('Teams gRPC call failed', { + path, + grpcStatus: statusName, + grpcCode, + httpStatus, + errorCode, + message, + }); + } + return new TeamsGrpcRequestError(httpStatus, grpcCode, errorCode, message); + } + + private grpcStatusToHttpStatus(grpcCode: status): HttpStatus { + switch (grpcCode) { + case status.INVALID_ARGUMENT: + return HttpStatus.BAD_REQUEST; + case status.UNAUTHENTICATED: + return HttpStatus.UNAUTHORIZED; + case status.PERMISSION_DENIED: + return HttpStatus.FORBIDDEN; + case status.NOT_FOUND: + return HttpStatus.NOT_FOUND; + case status.ABORTED: + case status.ALREADY_EXISTS: + return HttpStatus.CONFLICT; + case status.FAILED_PRECONDITION: + return HttpStatus.PRECONDITION_FAILED; + case status.RESOURCE_EXHAUSTED: + return HttpStatus.TOO_MANY_REQUESTS; + case status.UNIMPLEMENTED: + return HttpStatus.NOT_IMPLEMENTED; + case status.INTERNAL: + case status.DATA_LOSS: + return HttpStatus.INTERNAL_SERVER_ERROR; + case status.UNAVAILABLE: + return HttpStatus.SERVICE_UNAVAILABLE; + case status.DEADLINE_EXCEEDED: + return HttpStatus.GATEWAY_TIMEOUT; + case status.OUT_OF_RANGE: + return HttpStatus.BAD_REQUEST; + default: + return HttpStatus.BAD_GATEWAY; + } + } + + private grpcStatusToErrorCode(grpcCode: status): string { + switch (grpcCode) { + case status.INVALID_ARGUMENT: + return 'teams_invalid_argument'; + case status.UNAUTHENTICATED: + return 'teams_unauthenticated'; + case status.PERMISSION_DENIED: + return 'teams_forbidden'; + case status.NOT_FOUND: + return 'teams_not_found'; + case status.ABORTED: + case status.ALREADY_EXISTS: + return 'teams_conflict'; + case status.FAILED_PRECONDITION: + return 'teams_failed_precondition'; + case status.RESOURCE_EXHAUSTED: + return 'teams_resource_exhausted'; + case status.UNIMPLEMENTED: + return 'teams_unimplemented'; + case status.INTERNAL: + return 'teams_internal_error'; + case status.DATA_LOSS: + return 'teams_data_loss'; + case status.UNAVAILABLE: + return 'teams_unavailable'; + case status.DEADLINE_EXCEEDED: + return 'teams_timeout'; + default: + return 'teams_grpc_error'; + } + } + + private extractServiceErrorMessage(error: ServiceError): string { + const details = typeof error.details === 'string' ? error.details.trim() : ''; + const message = details || error.message || DEFAULT_ERROR_MESSAGE; + return message.trim() || DEFAULT_ERROR_MESSAGE; + } +} diff --git a/packages/platform-server/src/teams/teamsGrpc.token.ts b/packages/platform-server/src/teams/teamsGrpc.token.ts new file mode 100644 index 000000000..969537b3e --- /dev/null +++ b/packages/platform-server/src/teams/teamsGrpc.token.ts @@ -0,0 +1,5 @@ +import type { TeamsGrpcClient } from './teamsGrpc.client'; + +export const TEAMS_GRPC_CLIENT = Symbol('TEAMS_GRPC_CLIENT'); + +export type TeamsClient = TeamsGrpcClient; diff --git a/proto/agynio/api/teams/v1/teams.proto b/proto/agynio/api/teams/v1/teams.proto new file mode 100644 index 000000000..573c3d09e --- /dev/null +++ b/proto/agynio/api/teams/v1/teams.proto @@ -0,0 +1,421 @@ +syntax = "proto3"; + +package agynio.api.teams.v1; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +enum AgentWhenBusy { + AGENT_WHEN_BUSY_UNSPECIFIED = 0; + AGENT_WHEN_BUSY_WAIT = 1; + AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS = 2; +} + +enum AgentProcessBuffer { + AGENT_PROCESS_BUFFER_UNSPECIFIED = 0; + AGENT_PROCESS_BUFFER_ALL_TOGETHER = 1; + AGENT_PROCESS_BUFFER_ONE_BY_ONE = 2; +} + +enum ToolType { + TOOL_TYPE_UNSPECIFIED = 0; + TOOL_TYPE_MANAGE = 1; + TOOL_TYPE_MEMORY = 2; + TOOL_TYPE_SHELL_COMMAND = 3; + TOOL_TYPE_SEND_MESSAGE = 4; + TOOL_TYPE_SEND_SLACK_MESSAGE = 5; + TOOL_TYPE_REMIND_ME = 6; + TOOL_TYPE_GITHUB_CLONE_REPO = 7; + TOOL_TYPE_CALL_AGENT = 8; +} + +enum WorkspacePlatform { + WORKSPACE_PLATFORM_UNSPECIFIED = 0; + WORKSPACE_PLATFORM_LINUX_AMD64 = 1; + WORKSPACE_PLATFORM_LINUX_ARM64 = 2; + WORKSPACE_PLATFORM_AUTO = 3; +} + +enum MemoryBucketScope { + MEMORY_BUCKET_SCOPE_UNSPECIFIED = 0; + MEMORY_BUCKET_SCOPE_GLOBAL = 1; + MEMORY_BUCKET_SCOPE_PER_THREAD = 2; +} + +enum EntityType { + ENTITY_TYPE_UNSPECIFIED = 0; + ENTITY_TYPE_AGENT = 1; + ENTITY_TYPE_TOOL = 2; + ENTITY_TYPE_MCP_SERVER = 3; + ENTITY_TYPE_WORKSPACE_CONFIGURATION = 4; + ENTITY_TYPE_MEMORY_BUCKET = 5; +} + +enum AttachmentKind { + ATTACHMENT_KIND_UNSPECIFIED = 0; + ATTACHMENT_KIND_AGENT_TOOL = 1; + ATTACHMENT_KIND_AGENT_MEMORY_BUCKET = 2; + ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION = 3; + ATTACHMENT_KIND_AGENT_MCP_SERVER = 4; + ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION = 5; +} + +message AgentConfig { + string model = 1; + string system_prompt = 2; + uint32 debounce_ms = 3; + AgentWhenBusy when_busy = 4; + AgentProcessBuffer process_buffer = 5; + bool send_final_response_to_thread = 6; + uint32 summarization_keep_tokens = 7; + uint32 summarization_max_tokens = 8; + bool restrict_output = 9; + string restriction_message = 10; + uint32 restriction_max_injections = 11; + string name = 12; + string role = 13; +} + +message Agent { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + AgentConfig config = 6; +} + +message AgentCreateRequest { + string title = 1; + string description = 2; + AgentConfig config = 3; +} + +message AgentUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + AgentConfig config = 4; +} + +message GetAgentRequest { + string id = 1; +} + +message DeleteAgentRequest { + string id = 1; +} + +message ListAgentsRequest { + optional string q = 1; + optional uint32 page = 2; + optional uint32 per_page = 3; +} + +message PaginatedAgents { + repeated Agent items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message Tool { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + ToolType type = 4; + string name = 5; + string description = 6; + google.protobuf.Struct config = 7; +} + +message ToolCreateRequest { + ToolType type = 1; + string name = 2; + string description = 3; + google.protobuf.Struct config = 4; +} + +message ToolUpdateRequest { + string id = 1; + optional string name = 2; + optional string description = 3; + google.protobuf.Struct config = 4; +} + +message GetToolRequest { + string id = 1; +} + +message DeleteToolRequest { + string id = 1; +} + +message ListToolsRequest { + ToolType type = 1; + optional uint32 page = 2; + optional uint32 per_page = 3; +} + +message PaginatedTools { + repeated Tool items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message McpEnvItem { + string name = 1; + string value = 2; +} + +message McpServerRestartPolicy { + uint32 max_attempts = 1; + uint32 backoff_ms = 2; +} + +message McpServerConfig { + string namespace = 1; + string command = 2; + string workdir = 3; + repeated McpEnvItem env = 4; + optional uint32 request_timeout_ms = 5; + optional uint32 startup_timeout_ms = 6; + optional uint32 heartbeat_interval_ms = 7; + optional uint32 stale_timeout_ms = 8; + McpServerRestartPolicy restart = 9; +} + +message McpServer { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + McpServerConfig config = 6; +} + +message McpServerCreateRequest { + string title = 1; + string description = 2; + McpServerConfig config = 3; +} + +message McpServerUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + McpServerConfig config = 4; +} + +message GetMcpServerRequest { + string id = 1; +} + +message DeleteMcpServerRequest { + string id = 1; +} + +message ListMcpServersRequest { + optional uint32 page = 1; + optional uint32 per_page = 2; +} + +message PaginatedMcpServers { + repeated McpServer items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message WorkspaceEnvItem { + string name = 1; + string value = 2; +} + +message WorkspaceVolumeConfig { + bool enabled = 1; + string mount_path = 2; +} + +message WorkspaceConfig { + string image = 1; + repeated WorkspaceEnvItem env = 2; + string initial_script = 3; + google.protobuf.Value cpu_limit = 4; + google.protobuf.Value memory_limit = 5; + WorkspacePlatform platform = 6; + bool enable_dind = 7; + uint32 ttl_seconds = 8; + google.protobuf.Struct nix = 9; + WorkspaceVolumeConfig volumes = 10; +} + +message WorkspaceConfiguration { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + WorkspaceConfig config = 6; +} + +message WorkspaceConfigurationCreateRequest { + string title = 1; + string description = 2; + WorkspaceConfig config = 3; +} + +message WorkspaceConfigurationUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + WorkspaceConfig config = 4; +} + +message GetWorkspaceConfigurationRequest { + string id = 1; +} + +message DeleteWorkspaceConfigurationRequest { + string id = 1; +} + +message ListWorkspaceConfigurationsRequest { + optional uint32 page = 1; + optional uint32 per_page = 2; +} + +message PaginatedWorkspaceConfigurations { + repeated WorkspaceConfiguration items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message MemoryBucketConfig { + MemoryBucketScope scope = 1; + string collection_prefix = 2; +} + +message MemoryBucket { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + MemoryBucketConfig config = 6; +} + +message MemoryBucketCreateRequest { + string title = 1; + string description = 2; + MemoryBucketConfig config = 3; +} + +message MemoryBucketUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + MemoryBucketConfig config = 4; +} + +message GetMemoryBucketRequest { + string id = 1; +} + +message DeleteMemoryBucketRequest { + string id = 1; +} + +message ListMemoryBucketsRequest { + optional uint32 page = 1; + optional uint32 per_page = 2; +} + +message PaginatedMemoryBuckets { + repeated MemoryBucket items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message Attachment { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + AttachmentKind kind = 4; + EntityType source_type = 5; + string source_id = 6; + EntityType target_type = 7; + string target_id = 8; +} + +message AttachmentCreateRequest { + AttachmentKind kind = 1; + string source_id = 2; + string target_id = 3; +} + +message DeleteAttachmentRequest { + string id = 1; +} + +message ListAttachmentsRequest { + EntityType source_type = 1; + optional string source_id = 2; + EntityType target_type = 3; + optional string target_id = 4; + AttachmentKind kind = 5; + optional uint32 page = 6; + optional uint32 per_page = 7; +} + +message PaginatedAttachments { + repeated Attachment items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +service TeamsService { + rpc ListAgents(ListAgentsRequest) returns (PaginatedAgents); + rpc CreateAgent(AgentCreateRequest) returns (Agent); + rpc GetAgent(GetAgentRequest) returns (Agent); + rpc UpdateAgent(AgentUpdateRequest) returns (Agent); + rpc DeleteAgent(DeleteAgentRequest) returns (google.protobuf.Empty); + + rpc ListTools(ListToolsRequest) returns (PaginatedTools); + rpc CreateTool(ToolCreateRequest) returns (Tool); + rpc GetTool(GetToolRequest) returns (Tool); + rpc UpdateTool(ToolUpdateRequest) returns (Tool); + rpc DeleteTool(DeleteToolRequest) returns (google.protobuf.Empty); + + rpc ListMcpServers(ListMcpServersRequest) returns (PaginatedMcpServers); + rpc CreateMcpServer(McpServerCreateRequest) returns (McpServer); + rpc GetMcpServer(GetMcpServerRequest) returns (McpServer); + rpc UpdateMcpServer(McpServerUpdateRequest) returns (McpServer); + rpc DeleteMcpServer(DeleteMcpServerRequest) returns (google.protobuf.Empty); + + rpc ListWorkspaceConfigurations(ListWorkspaceConfigurationsRequest) + returns (PaginatedWorkspaceConfigurations); + rpc CreateWorkspaceConfiguration(WorkspaceConfigurationCreateRequest) + returns (WorkspaceConfiguration); + rpc GetWorkspaceConfiguration(GetWorkspaceConfigurationRequest) + returns (WorkspaceConfiguration); + rpc UpdateWorkspaceConfiguration(WorkspaceConfigurationUpdateRequest) + returns (WorkspaceConfiguration); + rpc DeleteWorkspaceConfiguration(DeleteWorkspaceConfigurationRequest) + returns (google.protobuf.Empty); + + rpc ListMemoryBuckets(ListMemoryBucketsRequest) returns (PaginatedMemoryBuckets); + rpc CreateMemoryBucket(MemoryBucketCreateRequest) returns (MemoryBucket); + rpc GetMemoryBucket(GetMemoryBucketRequest) returns (MemoryBucket); + rpc UpdateMemoryBucket(MemoryBucketUpdateRequest) returns (MemoryBucket); + rpc DeleteMemoryBucket(DeleteMemoryBucketRequest) returns (google.protobuf.Empty); + + rpc ListAttachments(ListAttachmentsRequest) returns (PaginatedAttachments); + rpc CreateAttachment(AttachmentCreateRequest) returns (Attachment); + rpc DeleteAttachment(DeleteAttachmentRequest) returns (google.protobuf.Empty); +} diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 000000000..b8699818a --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: . From 6d65d28eef44b556514dccf58c9916f7dbe4c059 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 11:11:11 +0000 Subject: [PATCH 02/43] refactor(platform-server): streamline teams grpc client --- .../__tests__/api.guard.mcp.command.test.ts | 38 -- .../__tests__/teams/teamsGrpc.client.test.ts | 169 +++++++ .../workspace.reuse.integration.test.ts | 4 +- .../platform-server/src/graph/graph.guard.ts | 59 --- .../src/teams/teamsGrpc.client.ts | 419 ++++++------------ 5 files changed, 315 insertions(+), 374 deletions(-) delete mode 100644 packages/platform-server/__tests__/api.guard.mcp.command.test.ts create mode 100644 packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts delete mode 100644 packages/platform-server/src/graph/graph.guard.ts diff --git a/packages/platform-server/__tests__/api.guard.mcp.command.test.ts b/packages/platform-server/__tests__/api.guard.mcp.command.test.ts deleted file mode 100644 index 8a8a8f2e8..000000000 --- a/packages/platform-server/__tests__/api.guard.mcp.command.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { enforceMcpCommandMutationGuard } from '../src/graph/graph.guard'; -import { GraphErrorCode } from '../src/graph/errors'; -import type { PersistedGraph, PersistedGraphUpsertRequest } from '../src/shared/types/graph.types'; -import type { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; - -describe('API guard: MCP command mutation forbidden', () => { - it('returns 409 when mutating MCP command while provisioned', async () => { - // Previous persisted graph with MCP server command 'a' - const before: PersistedGraph = { - name: 'main', - version: 0, - updatedAt: new Date().toISOString(), - nodes: [{ id: 'm1', template: 'mcpServer', config: { command: 'a' } }], - edges: [], - }; - // Next request attempting to change command to 'b' - const next: PersistedGraphUpsertRequest = { - name: 'main', - version: 1, - nodes: [{ id: 'm1', template: 'mcpServer', config: { command: 'b' } }], - edges: [], - }; - // Runtime stub: report node as provisioned (ready) - const runtime: Pick = { - getNodeStatus: (_id: string) => ({ provisionStatus: { state: 'ready' } }), - } as LiveGraphRuntime; - - try { - enforceMcpCommandMutationGuard(before, next, runtime as LiveGraphRuntime); - // Should not reach here - expect(false).toBe(true); - } catch (e) { - const code = (e as { code?: string }).code; - expect(code).toBe(GraphErrorCode.McpCommandMutationForbidden); - } - }); -}); diff --git a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts new file mode 100644 index 000000000..d0983f043 --- /dev/null +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Metadata, status, type CallOptions, type ServiceError } from '@grpc/grpc-js'; +import { HttpStatus } from '@nestjs/common'; +import { + ListAgentsRequestSchema, + type ListAgentsRequest, + type PaginatedAgents, +} from '../../src/proto/gen/agynio/api/teams/v1/teams_pb.js'; +import { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; +import { + TEAMS_SERVICE_LIST_AGENTS_PATH, + type TeamsServiceGrpcClientInstance, +} from '../../src/proto/grpc.js'; + +const DEFAULT_ERROR_MESSAGE = 'Teams service request failed'; + +type TeamsGrpcClientPrivate = { + grpcStatusToHttpStatus: (grpcCode: status) => HttpStatus; + grpcStatusToErrorCode: (grpcCode: status) => string; + extractServiceErrorMessage: (error: ServiceError) => string; + call: ( + path: string, + schema: unknown, + request: Req, + method: keyof TeamsServiceGrpcClientInstance, + timeoutMs?: number, + ) => Promise; +}; + +describe('TeamsGrpcClient', () => { + it('throws when address is blank', () => { + expect(() => new TeamsGrpcClient({ address: ' ' })).toThrow('TeamsGrpcClient requires a valid address'); + }); + + it.each([ + [status.INVALID_ARGUMENT, HttpStatus.BAD_REQUEST, 'teams_invalid_argument'], + [status.UNAUTHENTICATED, HttpStatus.UNAUTHORIZED, 'teams_unauthenticated'], + [status.PERMISSION_DENIED, HttpStatus.FORBIDDEN, 'teams_forbidden'], + [status.NOT_FOUND, HttpStatus.NOT_FOUND, 'teams_not_found'], + [status.ABORTED, HttpStatus.CONFLICT, 'teams_conflict'], + [status.ALREADY_EXISTS, HttpStatus.CONFLICT, 'teams_conflict'], + [status.FAILED_PRECONDITION, HttpStatus.PRECONDITION_FAILED, 'teams_failed_precondition'], + [status.RESOURCE_EXHAUSTED, HttpStatus.TOO_MANY_REQUESTS, 'teams_resource_exhausted'], + [status.UNIMPLEMENTED, HttpStatus.NOT_IMPLEMENTED, 'teams_unimplemented'], + [status.INTERNAL, HttpStatus.INTERNAL_SERVER_ERROR, 'teams_internal_error'], + [status.DATA_LOSS, HttpStatus.INTERNAL_SERVER_ERROR, 'teams_data_loss'], + [status.UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE, 'teams_unavailable'], + [status.DEADLINE_EXCEEDED, HttpStatus.GATEWAY_TIMEOUT, 'teams_timeout'], + [status.OUT_OF_RANGE, HttpStatus.BAD_REQUEST, 'teams_grpc_error'], + [status.CANCELLED, 499, 'teams_cancelled'], + [status.UNKNOWN, HttpStatus.BAD_GATEWAY, 'teams_grpc_error'], + ])('maps gRPC status %s to HTTP status and error code', (grpc, http, errorCode) => { + const client = new TeamsGrpcClient({ address: 'grpc://teams' }); + const internal = client as unknown as TeamsGrpcClientPrivate; + + expect(internal.grpcStatusToHttpStatus(grpc)).toBe(http); + expect(internal.grpcStatusToErrorCode(grpc)).toBe(errorCode); + }); + + it('prefers gRPC error details when extracting message', () => { + const client = new TeamsGrpcClient({ address: 'grpc://teams' }); + const internal = client as unknown as TeamsGrpcClientPrivate; + const error = Object.assign(new Error('fallback message'), { + details: 'detailed message', + }) as ServiceError; + + expect(internal.extractServiceErrorMessage(error)).toBe('detailed message'); + }); + + it('uses error message when details are blank', () => { + const client = new TeamsGrpcClient({ address: 'grpc://teams' }); + const internal = client as unknown as TeamsGrpcClientPrivate; + const error = Object.assign(new Error(' fallback message '), { + details: ' ', + }) as ServiceError; + + expect(internal.extractServiceErrorMessage(error)).toBe('fallback message'); + }); + + it('falls back to default message when details and message are empty', () => { + const client = new TeamsGrpcClient({ address: 'grpc://teams' }); + const internal = client as unknown as TeamsGrpcClientPrivate; + const error = Object.assign(new Error(''), { + details: '', + }) as ServiceError; + + expect(internal.extractServiceErrorMessage(error)).toBe(DEFAULT_ERROR_MESSAGE); + }); + + it('applies the default request timeout to gRPC calls', async () => { + vi.useFakeTimers(); + const now = new Date('2026-03-09T00:00:00.000Z'); + vi.setSystemTime(now); + + try { + const client = new TeamsGrpcClient({ address: 'grpc://teams', requestTimeoutMs: 5_000 }); + const captured: { options?: CallOptions } = {}; + + const listAgentsStub = vi.fn( + ( + _req: ListAgentsRequest, + _metadata: Metadata, + optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: PaginatedAgents) => void), + maybeCallback?: (err: ServiceError | null, response?: PaginatedAgents) => void, + ) => { + const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; + const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; + captured.options = options; + callback?.(null, { items: [], page: 1, per_page: 1, total: 0 } as PaginatedAgents); + }, + ); + + (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { + listAgents: listAgentsStub, + } as TeamsServiceGrpcClientInstance; + + await client.listAgents({}); + + expect(listAgentsStub).toHaveBeenCalledTimes(1); + expect(captured.options?.deadline?.getTime()).toBe(now.getTime() + 5_000); + } finally { + vi.useRealTimers(); + } + }); + + it('honors per-call timeout overrides', async () => { + vi.useFakeTimers(); + const now = new Date('2026-03-09T00:00:00.000Z'); + vi.setSystemTime(now); + + try { + const client = new TeamsGrpcClient({ address: 'grpc://teams', requestTimeoutMs: 5_000 }); + const captured: { options?: CallOptions } = {}; + + const listAgentsStub = vi.fn( + ( + _req: ListAgentsRequest, + _metadata: Metadata, + optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: PaginatedAgents) => void), + maybeCallback?: (err: ServiceError | null, response?: PaginatedAgents) => void, + ) => { + const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; + const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; + captured.options = options; + callback?.(null, { items: [], page: 1, per_page: 1, total: 0 } as PaginatedAgents); + }, + ); + + (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { + listAgents: listAgentsStub, + } as TeamsServiceGrpcClientInstance; + + const internal = client as unknown as TeamsGrpcClientPrivate; + + await internal.call( + TEAMS_SERVICE_LIST_AGENTS_PATH, + ListAgentsRequestSchema, + {}, + 'listAgents', + 12_500, + ); + + expect(listAgentsStub).toHaveBeenCalledTimes(1); + expect(captured.options?.deadline?.getTime()).toBe(now.getTime() + 12_500); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts index d1e5a404a..a93bd5b00 100644 --- a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts +++ b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts @@ -112,7 +112,7 @@ describeOrSkip('Docker workspace reuse lifecycle', () => { await dbHandle.stop(); } clearTestConfig(); - }, 120_000); + }, 240_000); it('reuses the container across shell and MCP-style execs', async () => { const threadId = randomUUID(); @@ -135,7 +135,7 @@ describeOrSkip('Docker workspace reuse lifecycle', () => { const finalRead = await finalHandle.exec(['sh', '-lc', 'cat /workspace/reuse.txt']); expect(finalRead.exitCode).toBe(0); expect(finalRead.stdout.trim()).toBe('shell-data'); - }, 180_000); + }, 240_000); it('reuses the container across sequential shell execs', async () => { const threadId = randomUUID(); diff --git a/packages/platform-server/src/graph/graph.guard.ts b/packages/platform-server/src/graph/graph.guard.ts deleted file mode 100644 index 6a4414484..000000000 --- a/packages/platform-server/src/graph/graph.guard.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import type { PersistedGraph, PersistedGraphUpsertRequest } from '../shared/types/graph.types'; -import { GraphErrorCode } from './errors'; -import type { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; -import type { NodeStatusState } from '../nodes/base/Node'; - -export type GuardError = Error & { code?: string }; - -function makeError(code: string, message: string): GuardError { - const e = new Error(message) as GuardError; - e.code = code; - return e; -} - -@Injectable() -export class GraphGuard { - /** - * Enforce that MCP node config.command cannot be mutated while the node is provisioned - * (i.e., provisionStatus.state !== 'not_ready'). - */ - enforceMcpCommandMutationGuard( - before: PersistedGraph | null, - next: PersistedGraphUpsertRequest, - runtime: LiveGraphRuntime, - ): void { - if (!before) return; // nothing to compare - const prev = new Map(before.nodes.map((n) => [n.id, n])); - for (const n of next.nodes || []) { - const was = prev.get(n.id); - if (!was) continue; - if (n.template !== 'mcpServer') continue; - const prevCmd = was.config?.command; - const nextCmd = n.config?.command; - if (prevCmd === nextCmd) continue; - const status = runtime.getNodeStatus(n.id); - const st: NodeStatusState | undefined = status?.provisionStatus?.state as NodeStatusState | undefined; - const state: NodeStatusState = st ?? 'not_ready'; - if (state !== 'not_ready') { - throw makeError( - GraphErrorCode.McpCommandMutationForbidden, - 'Cannot change MCP command while node is provisioned', - ); - } - } - } -} - -/** - * Enforce that MCP node config.command cannot be mutated while the node is provisioned - * (i.e., provisionStatus.state !== 'not_ready'). - */ -export function enforceMcpCommandMutationGuard( - before: PersistedGraph | null, - next: PersistedGraphUpsertRequest, - runtime: LiveGraphRuntime, -): void { - // Delegate to class to preserve existing imports while enabling DI usage elsewhere - new GraphGuard().enforceMcpCommandMutationGuard(before, next, runtime); -} diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts index 197b54e31..e99c8e0c4 100644 --- a/packages/platform-server/src/teams/teamsGrpc.client.ts +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -1,4 +1,4 @@ -import { create } from '@bufbuild/protobuf'; +import { create, type DescMessage } from '@bufbuild/protobuf'; import type { Empty } from '@bufbuild/protobuf/wkt'; import { HttpException, HttpStatus, Logger } from '@nestjs/common'; import { credentials, Metadata, status, type CallOptions, type ServiceError } from '@grpc/grpc-js'; @@ -112,6 +112,16 @@ type TeamsGrpcClientConfig = { requestTimeoutMs?: number; }; +type UnaryRpcCall = { + (request: Req, metadata: Metadata, callback: (err: ServiceError | null, response?: Res) => void): void; + ( + request: Req, + metadata: Metadata, + options: CallOptions, + callback: (err: ServiceError | null, response?: Res) => void, + ): void; +}; + const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_ERROR_MESSAGE = 'Teams service request failed'; @@ -148,430 +158,285 @@ export class TeamsGrpcClient { } async listAgents(request: ListAgentsRequest): Promise { - const message = create(ListAgentsRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_LIST_AGENTS_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.listAgents(req, metadata, options, callback); - return; - } - this.client.listAgents(req, metadata, callback); - }, + ListAgentsRequestSchema, + request, + 'listAgents', ); } async createAgent(request: AgentCreateRequest): Promise { - const message = create(AgentCreateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_CREATE_AGENT_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.createAgent(req, metadata, options, callback); - return; - } - this.client.createAgent(req, metadata, callback); - }, + AgentCreateRequestSchema, + request, + 'createAgent', ); } async getAgent(request: GetAgentRequest): Promise { - const message = create(GetAgentRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_GET_AGENT_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.getAgent(req, metadata, options, callback); - return; - } - this.client.getAgent(req, metadata, callback); - }, + GetAgentRequestSchema, + request, + 'getAgent', ); } async updateAgent(request: AgentUpdateRequest): Promise { - const message = create(AgentUpdateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_UPDATE_AGENT_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.updateAgent(req, metadata, options, callback); - return; - } - this.client.updateAgent(req, metadata, callback); - }, + AgentUpdateRequestSchema, + request, + 'updateAgent', ); } async deleteAgent(request: DeleteAgentRequest): Promise { - const message = create(DeleteAgentRequestSchema, request); - await this.unary( + await this.call( TEAMS_SERVICE_DELETE_AGENT_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.deleteAgent(req, metadata, options, callback); - return; - } - this.client.deleteAgent(req, metadata, callback); - }, + DeleteAgentRequestSchema, + request, + 'deleteAgent', ); } async listTools(request: ListToolsRequest): Promise { - const message = create(ListToolsRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_LIST_TOOLS_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.listTools(req, metadata, options, callback); - return; - } - this.client.listTools(req, metadata, callback); - }, + ListToolsRequestSchema, + request, + 'listTools', ); } async createTool(request: ToolCreateRequest): Promise { - const message = create(ToolCreateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_CREATE_TOOL_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.createTool(req, metadata, options, callback); - return; - } - this.client.createTool(req, metadata, callback); - }, + ToolCreateRequestSchema, + request, + 'createTool', ); } async getTool(request: GetToolRequest): Promise { - const message = create(GetToolRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_GET_TOOL_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.getTool(req, metadata, options, callback); - return; - } - this.client.getTool(req, metadata, callback); - }, + GetToolRequestSchema, + request, + 'getTool', ); } async updateTool(request: ToolUpdateRequest): Promise { - const message = create(ToolUpdateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_UPDATE_TOOL_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.updateTool(req, metadata, options, callback); - return; - } - this.client.updateTool(req, metadata, callback); - }, + ToolUpdateRequestSchema, + request, + 'updateTool', ); } async deleteTool(request: DeleteToolRequest): Promise { - const message = create(DeleteToolRequestSchema, request); - await this.unary( + await this.call( TEAMS_SERVICE_DELETE_TOOL_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.deleteTool(req, metadata, options, callback); - return; - } - this.client.deleteTool(req, metadata, callback); - }, + DeleteToolRequestSchema, + request, + 'deleteTool', ); } async listMcpServers(request: ListMcpServersRequest): Promise { - const message = create(ListMcpServersRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.listMcpServers(req, metadata, options, callback); - return; - } - this.client.listMcpServers(req, metadata, callback); - }, + ListMcpServersRequestSchema, + request, + 'listMcpServers', ); } async createMcpServer(request: McpServerCreateRequest): Promise { - const message = create(McpServerCreateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.createMcpServer(req, metadata, options, callback); - return; - } - this.client.createMcpServer(req, metadata, callback); - }, + McpServerCreateRequestSchema, + request, + 'createMcpServer', ); } async getMcpServer(request: GetMcpServerRequest): Promise { - const message = create(GetMcpServerRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_GET_MCP_SERVER_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.getMcpServer(req, metadata, options, callback); - return; - } - this.client.getMcpServer(req, metadata, callback); - }, + GetMcpServerRequestSchema, + request, + 'getMcpServer', ); } async updateMcpServer(request: McpServerUpdateRequest): Promise { - const message = create(McpServerUpdateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.updateMcpServer(req, metadata, options, callback); - return; - } - this.client.updateMcpServer(req, metadata, callback); - }, + McpServerUpdateRequestSchema, + request, + 'updateMcpServer', ); } async deleteMcpServer(request: DeleteMcpServerRequest): Promise { - const message = create(DeleteMcpServerRequestSchema, request); - await this.unary( + await this.call( TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.deleteMcpServer(req, metadata, options, callback); - return; - } - this.client.deleteMcpServer(req, metadata, callback); - }, + DeleteMcpServerRequestSchema, + request, + 'deleteMcpServer', ); } async listWorkspaceConfigurations( request: ListWorkspaceConfigurationsRequest, ): Promise { - const message = create(ListWorkspaceConfigurationsRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.listWorkspaceConfigurations(req, metadata, options, callback); - return; - } - this.client.listWorkspaceConfigurations(req, metadata, callback); - }, + ListWorkspaceConfigurationsRequestSchema, + request, + 'listWorkspaceConfigurations', ); } async createWorkspaceConfiguration( request: WorkspaceConfigurationCreateRequest, ): Promise { - const message = create(WorkspaceConfigurationCreateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.createWorkspaceConfiguration(req, metadata, options, callback); - return; - } - this.client.createWorkspaceConfiguration(req, metadata, callback); - }, + WorkspaceConfigurationCreateRequestSchema, + request, + 'createWorkspaceConfiguration', ); } async getWorkspaceConfiguration( request: GetWorkspaceConfigurationRequest, ): Promise { - const message = create(GetWorkspaceConfigurationRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.getWorkspaceConfiguration(req, metadata, options, callback); - return; - } - this.client.getWorkspaceConfiguration(req, metadata, callback); - }, + GetWorkspaceConfigurationRequestSchema, + request, + 'getWorkspaceConfiguration', ); } async updateWorkspaceConfiguration( request: WorkspaceConfigurationUpdateRequest, ): Promise { - const message = create(WorkspaceConfigurationUpdateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.updateWorkspaceConfiguration(req, metadata, options, callback); - return; - } - this.client.updateWorkspaceConfiguration(req, metadata, callback); - }, + WorkspaceConfigurationUpdateRequestSchema, + request, + 'updateWorkspaceConfiguration', ); } async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { - const message = create(DeleteWorkspaceConfigurationRequestSchema, request); - await this.unary( + await this.call( TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.deleteWorkspaceConfiguration(req, metadata, options, callback); - return; - } - this.client.deleteWorkspaceConfiguration(req, metadata, callback); - }, + DeleteWorkspaceConfigurationRequestSchema, + request, + 'deleteWorkspaceConfiguration', ); } async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { - const message = create(ListMemoryBucketsRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.listMemoryBuckets(req, metadata, options, callback); - return; - } - this.client.listMemoryBuckets(req, metadata, callback); - }, + ListMemoryBucketsRequestSchema, + request, + 'listMemoryBuckets', ); } async createMemoryBucket(request: MemoryBucketCreateRequest): Promise { - const message = create(MemoryBucketCreateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.createMemoryBucket(req, metadata, options, callback); - return; - } - this.client.createMemoryBucket(req, metadata, callback); - }, + MemoryBucketCreateRequestSchema, + request, + 'createMemoryBucket', ); } async getMemoryBucket(request: GetMemoryBucketRequest): Promise { - const message = create(GetMemoryBucketRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.getMemoryBucket(req, metadata, options, callback); - return; - } - this.client.getMemoryBucket(req, metadata, callback); - }, + GetMemoryBucketRequestSchema, + request, + 'getMemoryBucket', ); } async updateMemoryBucket(request: MemoryBucketUpdateRequest): Promise { - const message = create(MemoryBucketUpdateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.updateMemoryBucket(req, metadata, options, callback); - return; - } - this.client.updateMemoryBucket(req, metadata, callback); - }, + MemoryBucketUpdateRequestSchema, + request, + 'updateMemoryBucket', ); } async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { - const message = create(DeleteMemoryBucketRequestSchema, request); - await this.unary( + await this.call( TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.deleteMemoryBucket(req, metadata, options, callback); - return; - } - this.client.deleteMemoryBucket(req, metadata, callback); - }, + DeleteMemoryBucketRequestSchema, + request, + 'deleteMemoryBucket', ); } async listAttachments(request: ListAttachmentsRequest): Promise { - const message = create(ListAttachmentsRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.listAttachments(req, metadata, options, callback); - return; - } - this.client.listAttachments(req, metadata, callback); - }, + ListAttachmentsRequestSchema, + request, + 'listAttachments', ); } async createAttachment(request: AttachmentCreateRequest): Promise { - const message = create(AttachmentCreateRequestSchema, request); - return this.unary( + return this.call( TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - message, - (req, metadata, options, callback) => { - if (options) { - this.client.createAttachment(req, metadata, options, callback); - return; - } - this.client.createAttachment(req, metadata, callback); - }, + AttachmentCreateRequestSchema, + request, + 'createAttachment', ); } async deleteAttachment(request: DeleteAttachmentRequest): Promise { - const message = create(DeleteAttachmentRequestSchema, request); - await this.unary( + await this.call( TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + DeleteAttachmentRequestSchema, + request, + 'deleteAttachment', + ); + } + + private call( + path: string, + schema: DescMessage, + request: Req, + method: keyof TeamsServiceGrpcClientInstance, + timeoutMs?: number, + ): Promise { + const message = create(schema, request as never) as Req; + const fn = this.client[method] as unknown as UnaryRpcCall; + return this.unary( + path, message, (req, metadata, options, callback) => { if (options) { - this.client.deleteAttachment(req, metadata, options, callback); + fn(req, metadata, options, callback); return; } - this.client.deleteAttachment(req, metadata, callback); + fn(req, metadata, callback); }, + timeoutMs, ); } @@ -665,6 +530,8 @@ export class TeamsGrpcClient { return HttpStatus.GATEWAY_TIMEOUT; case status.OUT_OF_RANGE: return HttpStatus.BAD_REQUEST; + case status.CANCELLED: + return 499 as HttpStatus; default: return HttpStatus.BAD_GATEWAY; } @@ -697,6 +564,8 @@ export class TeamsGrpcClient { return 'teams_unavailable'; case status.DEADLINE_EXCEEDED: return 'teams_timeout'; + case status.CANCELLED: + return 'teams_cancelled'; default: return 'teams_grpc_error'; } From c99d79dcda5774cf9c49e8aba5e3dd94305e01e7 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 17:31:54 +0000 Subject: [PATCH 03/43] fix(proto): drop local teams schema --- package.json | 2 +- .../__tests__/teams/teamsGrpc.client.test.ts | 14 +- packages/platform-server/src/proto/grpc.ts | 141 +++--- .../src/teams/teamsGrpc.client.ts | 167 +++---- proto/agynio/api/teams/v1/teams.proto | 421 ------------------ proto/buf.yaml | 3 - 6 files changed, 177 insertions(+), 571 deletions(-) delete mode 100644 proto/agynio/api/teams/v1/teams.proto delete mode 100644 proto/buf.yaml diff --git a/package.json b/package.json index ae40743f1..f62a26d19 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "pnpm -r --workspace-concurrency=1 run --if-present test", "convert-graphs": "pnpm --filter @agyn/graph-converter exec graph-converter", "postinstall": "pnpm -r --if-present run prepare", - "proto:generate": "buf generate buf.build/agynio/api && buf generate proto", + "proto:generate": "buf generate buf.build/agynio/api", "deps:up:podman": "podman compose up -d" }, "keywords": [], diff --git a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts index d0983f043..a23d12948 100644 --- a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -4,7 +4,7 @@ import { HttpStatus } from '@nestjs/common'; import { ListAgentsRequestSchema, type ListAgentsRequest, - type PaginatedAgents, + type ListAgentsResponse, } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb.js'; import { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; import { @@ -100,13 +100,13 @@ describe('TeamsGrpcClient', () => { ( _req: ListAgentsRequest, _metadata: Metadata, - optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: PaginatedAgents) => void), - maybeCallback?: (err: ServiceError | null, response?: PaginatedAgents) => void, + optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: ListAgentsResponse) => void), + maybeCallback?: (err: ServiceError | null, response?: ListAgentsResponse) => void, ) => { const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; captured.options = options; - callback?.(null, { items: [], page: 1, per_page: 1, total: 0 } as PaginatedAgents); + callback?.(null, { agents: [], nextPageToken: '' } as ListAgentsResponse); }, ); @@ -136,13 +136,13 @@ describe('TeamsGrpcClient', () => { ( _req: ListAgentsRequest, _metadata: Metadata, - optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: PaginatedAgents) => void), - maybeCallback?: (err: ServiceError | null, response?: PaginatedAgents) => void, + optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: ListAgentsResponse) => void), + maybeCallback?: (err: ServiceError | null, response?: ListAgentsResponse) => void, ) => { const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; captured.options = options; - callback?.(null, { items: [], page: 1, per_page: 1, total: 0 } as PaginatedAgents); + callback?.(null, { agents: [], nextPageToken: '' } as ListAgentsResponse); }, ); diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts index 5a3d37cdb..c952d0719 100644 --- a/packages/platform-server/src/proto/grpc.ts +++ b/packages/platform-server/src/proto/grpc.ts @@ -2,7 +2,6 @@ import { makeGenericClientConstructor } from '@grpc/grpc-js'; import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; import { toBinary, fromBinary } from '@bufbuild/protobuf'; import type { DescMessage } from '@bufbuild/protobuf'; -import { EmptySchema } from '@bufbuild/protobuf/wkt'; import { CancelExecutionRequestSchema, CancelExecutionResponseSchema, @@ -36,46 +35,62 @@ import { TouchWorkloadResponseSchema, } from './gen/agynio/api/runner/v1/runner_pb.js'; import { - AgentCreateRequestSchema, - AgentSchema, - AgentUpdateRequestSchema, - AttachmentCreateRequestSchema, - AttachmentSchema, + CreateAgentRequestSchema, + CreateAgentResponseSchema, + CreateAttachmentRequestSchema, + CreateAttachmentResponseSchema, + CreateMcpServerRequestSchema, + CreateMcpServerResponseSchema, + CreateMemoryBucketRequestSchema, + CreateMemoryBucketResponseSchema, + CreateToolRequestSchema, + CreateToolResponseSchema, + CreateWorkspaceConfigurationRequestSchema, + CreateWorkspaceConfigurationResponseSchema, DeleteAgentRequestSchema, + DeleteAgentResponseSchema, DeleteAttachmentRequestSchema, + DeleteAttachmentResponseSchema, DeleteMcpServerRequestSchema, + DeleteMcpServerResponseSchema, DeleteMemoryBucketRequestSchema, + DeleteMemoryBucketResponseSchema, DeleteToolRequestSchema, + DeleteToolResponseSchema, DeleteWorkspaceConfigurationRequestSchema, + DeleteWorkspaceConfigurationResponseSchema, GetAgentRequestSchema, + GetAgentResponseSchema, GetMcpServerRequestSchema, + GetMcpServerResponseSchema, GetMemoryBucketRequestSchema, + GetMemoryBucketResponseSchema, GetToolRequestSchema, + GetToolResponseSchema, GetWorkspaceConfigurationRequestSchema, + GetWorkspaceConfigurationResponseSchema, ListAgentsRequestSchema, + ListAgentsResponseSchema, ListAttachmentsRequestSchema, + ListAttachmentsResponseSchema, ListMcpServersRequestSchema, + ListMcpServersResponseSchema, ListMemoryBucketsRequestSchema, + ListMemoryBucketsResponseSchema, ListToolsRequestSchema, + ListToolsResponseSchema, ListWorkspaceConfigurationsRequestSchema, - McpServerCreateRequestSchema, - McpServerSchema, - McpServerUpdateRequestSchema, - MemoryBucketCreateRequestSchema, - MemoryBucketSchema, - MemoryBucketUpdateRequestSchema, - PaginatedAgentsSchema, - PaginatedAttachmentsSchema, - PaginatedMcpServersSchema, - PaginatedMemoryBucketsSchema, - PaginatedToolsSchema, - PaginatedWorkspaceConfigurationsSchema, - ToolCreateRequestSchema, - ToolSchema, - ToolUpdateRequestSchema, - WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationSchema, - WorkspaceConfigurationUpdateRequestSchema, + ListWorkspaceConfigurationsResponseSchema, + UpdateAgentRequestSchema, + UpdateAgentResponseSchema, + UpdateMcpServerRequestSchema, + UpdateMcpServerResponseSchema, + UpdateMemoryBucketRequestSchema, + UpdateMemoryBucketResponseSchema, + UpdateToolRequestSchema, + UpdateToolResponseSchema, + UpdateWorkspaceConfigurationRequestSchema, + UpdateWorkspaceConfigurationResponseSchema, } from './gen/agynio/api/teams/v1/teams_pb.js'; const unaryDefinition = ( @@ -262,142 +277,142 @@ export const teamsServiceGrpcDefinition: ServiceDefinition = { listAgents: unaryDefinition( TEAMS_SERVICE_LIST_AGENTS_PATH, ListAgentsRequestSchema, - PaginatedAgentsSchema, + ListAgentsResponseSchema, ), createAgent: unaryDefinition( TEAMS_SERVICE_CREATE_AGENT_PATH, - AgentCreateRequestSchema, - AgentSchema, + CreateAgentRequestSchema, + CreateAgentResponseSchema, ), getAgent: unaryDefinition( TEAMS_SERVICE_GET_AGENT_PATH, GetAgentRequestSchema, - AgentSchema, + GetAgentResponseSchema, ), updateAgent: unaryDefinition( TEAMS_SERVICE_UPDATE_AGENT_PATH, - AgentUpdateRequestSchema, - AgentSchema, + UpdateAgentRequestSchema, + UpdateAgentResponseSchema, ), deleteAgent: unaryDefinition( TEAMS_SERVICE_DELETE_AGENT_PATH, DeleteAgentRequestSchema, - EmptySchema, + DeleteAgentResponseSchema, ), listTools: unaryDefinition( TEAMS_SERVICE_LIST_TOOLS_PATH, ListToolsRequestSchema, - PaginatedToolsSchema, + ListToolsResponseSchema, ), createTool: unaryDefinition( TEAMS_SERVICE_CREATE_TOOL_PATH, - ToolCreateRequestSchema, - ToolSchema, + CreateToolRequestSchema, + CreateToolResponseSchema, ), getTool: unaryDefinition( TEAMS_SERVICE_GET_TOOL_PATH, GetToolRequestSchema, - ToolSchema, + GetToolResponseSchema, ), updateTool: unaryDefinition( TEAMS_SERVICE_UPDATE_TOOL_PATH, - ToolUpdateRequestSchema, - ToolSchema, + UpdateToolRequestSchema, + UpdateToolResponseSchema, ), deleteTool: unaryDefinition( TEAMS_SERVICE_DELETE_TOOL_PATH, DeleteToolRequestSchema, - EmptySchema, + DeleteToolResponseSchema, ), listMcpServers: unaryDefinition( TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, ListMcpServersRequestSchema, - PaginatedMcpServersSchema, + ListMcpServersResponseSchema, ), createMcpServer: unaryDefinition( TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - McpServerCreateRequestSchema, - McpServerSchema, + CreateMcpServerRequestSchema, + CreateMcpServerResponseSchema, ), getMcpServer: unaryDefinition( TEAMS_SERVICE_GET_MCP_SERVER_PATH, GetMcpServerRequestSchema, - McpServerSchema, + GetMcpServerResponseSchema, ), updateMcpServer: unaryDefinition( TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - McpServerUpdateRequestSchema, - McpServerSchema, + UpdateMcpServerRequestSchema, + UpdateMcpServerResponseSchema, ), deleteMcpServer: unaryDefinition( TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, DeleteMcpServerRequestSchema, - EmptySchema, + DeleteMcpServerResponseSchema, ), listWorkspaceConfigurations: unaryDefinition( TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, ListWorkspaceConfigurationsRequestSchema, - PaginatedWorkspaceConfigurationsSchema, + ListWorkspaceConfigurationsResponseSchema, ), createWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationSchema, + CreateWorkspaceConfigurationRequestSchema, + CreateWorkspaceConfigurationResponseSchema, ), getWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, GetWorkspaceConfigurationRequestSchema, - WorkspaceConfigurationSchema, + GetWorkspaceConfigurationResponseSchema, ), updateWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, - WorkspaceConfigurationUpdateRequestSchema, - WorkspaceConfigurationSchema, + UpdateWorkspaceConfigurationRequestSchema, + UpdateWorkspaceConfigurationResponseSchema, ), deleteWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, DeleteWorkspaceConfigurationRequestSchema, - EmptySchema, + DeleteWorkspaceConfigurationResponseSchema, ), listMemoryBuckets: unaryDefinition( TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, ListMemoryBucketsRequestSchema, - PaginatedMemoryBucketsSchema, + ListMemoryBucketsResponseSchema, ), createMemoryBucket: unaryDefinition( TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - MemoryBucketCreateRequestSchema, - MemoryBucketSchema, + CreateMemoryBucketRequestSchema, + CreateMemoryBucketResponseSchema, ), getMemoryBucket: unaryDefinition( TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, GetMemoryBucketRequestSchema, - MemoryBucketSchema, + GetMemoryBucketResponseSchema, ), updateMemoryBucket: unaryDefinition( TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - MemoryBucketUpdateRequestSchema, - MemoryBucketSchema, + UpdateMemoryBucketRequestSchema, + UpdateMemoryBucketResponseSchema, ), deleteMemoryBucket: unaryDefinition( TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, DeleteMemoryBucketRequestSchema, - EmptySchema, + DeleteMemoryBucketResponseSchema, ), listAttachments: unaryDefinition( TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, ListAttachmentsRequestSchema, - PaginatedAttachmentsSchema, + ListAttachmentsResponseSchema, ), createAttachment: unaryDefinition( TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - AttachmentCreateRequestSchema, - AttachmentSchema, + CreateAttachmentRequestSchema, + CreateAttachmentResponseSchema, ), deleteAttachment: unaryDefinition( TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, DeleteAttachmentRequestSchema, - EmptySchema, + DeleteAttachmentResponseSchema, ), }; diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts index e99c8e0c4..c44e24fe4 100644 --- a/packages/platform-server/src/teams/teamsGrpc.client.ts +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -1,11 +1,13 @@ import { create, type DescMessage } from '@bufbuild/protobuf'; -import type { Empty } from '@bufbuild/protobuf/wkt'; import { HttpException, HttpStatus, Logger } from '@nestjs/common'; import { credentials, Metadata, status, type CallOptions, type ServiceError } from '@grpc/grpc-js'; import { - AgentCreateRequestSchema, - AgentUpdateRequestSchema, - AttachmentCreateRequestSchema, + CreateAgentRequestSchema, + CreateAttachmentRequestSchema, + CreateMcpServerRequestSchema, + CreateMemoryBucketRequestSchema, + CreateToolRequestSchema, + CreateWorkspaceConfigurationRequestSchema, DeleteAgentRequestSchema, DeleteAttachmentRequestSchema, DeleteMcpServerRequestSchema, @@ -23,56 +25,69 @@ import { ListMemoryBucketsRequestSchema, ListToolsRequestSchema, ListWorkspaceConfigurationsRequestSchema, - McpServerCreateRequestSchema, - McpServerUpdateRequestSchema, - MemoryBucketCreateRequestSchema, - MemoryBucketUpdateRequestSchema, - ToolCreateRequestSchema, - ToolUpdateRequestSchema, - WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationUpdateRequestSchema, + UpdateAgentRequestSchema, + UpdateMcpServerRequestSchema, + UpdateMemoryBucketRequestSchema, + UpdateToolRequestSchema, + UpdateWorkspaceConfigurationRequestSchema, } from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; import type { - Agent, - AgentCreateRequest, - AgentUpdateRequest, - Attachment, - AttachmentCreateRequest, + CreateAgentRequest, + CreateAgentResponse, + CreateAttachmentRequest, + CreateAttachmentResponse, + CreateMcpServerRequest, + CreateMcpServerResponse, + CreateMemoryBucketRequest, + CreateMemoryBucketResponse, + CreateToolRequest, + CreateToolResponse, + CreateWorkspaceConfigurationRequest, + CreateWorkspaceConfigurationResponse, DeleteAgentRequest, + DeleteAgentResponse, DeleteAttachmentRequest, + DeleteAttachmentResponse, DeleteMcpServerRequest, + DeleteMcpServerResponse, DeleteMemoryBucketRequest, + DeleteMemoryBucketResponse, DeleteToolRequest, + DeleteToolResponse, DeleteWorkspaceConfigurationRequest, + DeleteWorkspaceConfigurationResponse, GetAgentRequest, + GetAgentResponse, GetMcpServerRequest, + GetMcpServerResponse, GetMemoryBucketRequest, + GetMemoryBucketResponse, GetToolRequest, + GetToolResponse, GetWorkspaceConfigurationRequest, + GetWorkspaceConfigurationResponse, ListAgentsRequest, + ListAgentsResponse, ListAttachmentsRequest, + ListAttachmentsResponse, ListMcpServersRequest, + ListMcpServersResponse, ListMemoryBucketsRequest, + ListMemoryBucketsResponse, ListToolsRequest, + ListToolsResponse, ListWorkspaceConfigurationsRequest, - McpServer, - McpServerCreateRequest, - McpServerUpdateRequest, - MemoryBucket, - MemoryBucketCreateRequest, - MemoryBucketUpdateRequest, - PaginatedAgents, - PaginatedAttachments, - PaginatedMcpServers, - PaginatedMemoryBuckets, - PaginatedTools, - PaginatedWorkspaceConfigurations, - Tool, - ToolCreateRequest, - ToolUpdateRequest, - WorkspaceConfiguration, - WorkspaceConfigurationCreateRequest, - WorkspaceConfigurationUpdateRequest, + ListWorkspaceConfigurationsResponse, + UpdateAgentRequest, + UpdateAgentResponse, + UpdateMcpServerRequest, + UpdateMcpServerResponse, + UpdateMemoryBucketRequest, + UpdateMemoryBucketResponse, + UpdateToolRequest, + UpdateToolResponse, + UpdateWorkspaceConfigurationRequest, + UpdateWorkspaceConfigurationResponse, } from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; import { TeamsServiceGrpcClient, @@ -157,7 +172,7 @@ export class TeamsGrpcClient { return this.endpoint; } - async listAgents(request: ListAgentsRequest): Promise { + async listAgents(request: ListAgentsRequest): Promise { return this.call( TEAMS_SERVICE_LIST_AGENTS_PATH, ListAgentsRequestSchema, @@ -166,16 +181,16 @@ export class TeamsGrpcClient { ); } - async createAgent(request: AgentCreateRequest): Promise { + async createAgent(request: CreateAgentRequest): Promise { return this.call( TEAMS_SERVICE_CREATE_AGENT_PATH, - AgentCreateRequestSchema, + CreateAgentRequestSchema, request, 'createAgent', ); } - async getAgent(request: GetAgentRequest): Promise { + async getAgent(request: GetAgentRequest): Promise { return this.call( TEAMS_SERVICE_GET_AGENT_PATH, GetAgentRequestSchema, @@ -184,17 +199,17 @@ export class TeamsGrpcClient { ); } - async updateAgent(request: AgentUpdateRequest): Promise { + async updateAgent(request: UpdateAgentRequest): Promise { return this.call( TEAMS_SERVICE_UPDATE_AGENT_PATH, - AgentUpdateRequestSchema, + UpdateAgentRequestSchema, request, 'updateAgent', ); } async deleteAgent(request: DeleteAgentRequest): Promise { - await this.call( + await this.call( TEAMS_SERVICE_DELETE_AGENT_PATH, DeleteAgentRequestSchema, request, @@ -202,7 +217,7 @@ export class TeamsGrpcClient { ); } - async listTools(request: ListToolsRequest): Promise { + async listTools(request: ListToolsRequest): Promise { return this.call( TEAMS_SERVICE_LIST_TOOLS_PATH, ListToolsRequestSchema, @@ -211,16 +226,16 @@ export class TeamsGrpcClient { ); } - async createTool(request: ToolCreateRequest): Promise { + async createTool(request: CreateToolRequest): Promise { return this.call( TEAMS_SERVICE_CREATE_TOOL_PATH, - ToolCreateRequestSchema, + CreateToolRequestSchema, request, 'createTool', ); } - async getTool(request: GetToolRequest): Promise { + async getTool(request: GetToolRequest): Promise { return this.call( TEAMS_SERVICE_GET_TOOL_PATH, GetToolRequestSchema, @@ -229,17 +244,17 @@ export class TeamsGrpcClient { ); } - async updateTool(request: ToolUpdateRequest): Promise { + async updateTool(request: UpdateToolRequest): Promise { return this.call( TEAMS_SERVICE_UPDATE_TOOL_PATH, - ToolUpdateRequestSchema, + UpdateToolRequestSchema, request, 'updateTool', ); } async deleteTool(request: DeleteToolRequest): Promise { - await this.call( + await this.call( TEAMS_SERVICE_DELETE_TOOL_PATH, DeleteToolRequestSchema, request, @@ -247,7 +262,7 @@ export class TeamsGrpcClient { ); } - async listMcpServers(request: ListMcpServersRequest): Promise { + async listMcpServers(request: ListMcpServersRequest): Promise { return this.call( TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, ListMcpServersRequestSchema, @@ -256,16 +271,16 @@ export class TeamsGrpcClient { ); } - async createMcpServer(request: McpServerCreateRequest): Promise { + async createMcpServer(request: CreateMcpServerRequest): Promise { return this.call( TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - McpServerCreateRequestSchema, + CreateMcpServerRequestSchema, request, 'createMcpServer', ); } - async getMcpServer(request: GetMcpServerRequest): Promise { + async getMcpServer(request: GetMcpServerRequest): Promise { return this.call( TEAMS_SERVICE_GET_MCP_SERVER_PATH, GetMcpServerRequestSchema, @@ -274,17 +289,17 @@ export class TeamsGrpcClient { ); } - async updateMcpServer(request: McpServerUpdateRequest): Promise { + async updateMcpServer(request: UpdateMcpServerRequest): Promise { return this.call( TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - McpServerUpdateRequestSchema, + UpdateMcpServerRequestSchema, request, 'updateMcpServer', ); } async deleteMcpServer(request: DeleteMcpServerRequest): Promise { - await this.call( + await this.call( TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, DeleteMcpServerRequestSchema, request, @@ -294,7 +309,7 @@ export class TeamsGrpcClient { async listWorkspaceConfigurations( request: ListWorkspaceConfigurationsRequest, - ): Promise { + ): Promise { return this.call( TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, ListWorkspaceConfigurationsRequestSchema, @@ -304,11 +319,11 @@ export class TeamsGrpcClient { } async createWorkspaceConfiguration( - request: WorkspaceConfigurationCreateRequest, - ): Promise { + request: CreateWorkspaceConfigurationRequest, + ): Promise { return this.call( TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - WorkspaceConfigurationCreateRequestSchema, + CreateWorkspaceConfigurationRequestSchema, request, 'createWorkspaceConfiguration', ); @@ -316,7 +331,7 @@ export class TeamsGrpcClient { async getWorkspaceConfiguration( request: GetWorkspaceConfigurationRequest, - ): Promise { + ): Promise { return this.call( TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, GetWorkspaceConfigurationRequestSchema, @@ -326,18 +341,18 @@ export class TeamsGrpcClient { } async updateWorkspaceConfiguration( - request: WorkspaceConfigurationUpdateRequest, - ): Promise { + request: UpdateWorkspaceConfigurationRequest, + ): Promise { return this.call( TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, - WorkspaceConfigurationUpdateRequestSchema, + UpdateWorkspaceConfigurationRequestSchema, request, 'updateWorkspaceConfiguration', ); } async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { - await this.call( + await this.call( TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, DeleteWorkspaceConfigurationRequestSchema, request, @@ -345,7 +360,7 @@ export class TeamsGrpcClient { ); } - async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { + async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { return this.call( TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, ListMemoryBucketsRequestSchema, @@ -354,16 +369,16 @@ export class TeamsGrpcClient { ); } - async createMemoryBucket(request: MemoryBucketCreateRequest): Promise { + async createMemoryBucket(request: CreateMemoryBucketRequest): Promise { return this.call( TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - MemoryBucketCreateRequestSchema, + CreateMemoryBucketRequestSchema, request, 'createMemoryBucket', ); } - async getMemoryBucket(request: GetMemoryBucketRequest): Promise { + async getMemoryBucket(request: GetMemoryBucketRequest): Promise { return this.call( TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, GetMemoryBucketRequestSchema, @@ -372,17 +387,17 @@ export class TeamsGrpcClient { ); } - async updateMemoryBucket(request: MemoryBucketUpdateRequest): Promise { + async updateMemoryBucket(request: UpdateMemoryBucketRequest): Promise { return this.call( TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - MemoryBucketUpdateRequestSchema, + UpdateMemoryBucketRequestSchema, request, 'updateMemoryBucket', ); } async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { - await this.call( + await this.call( TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, DeleteMemoryBucketRequestSchema, request, @@ -390,7 +405,7 @@ export class TeamsGrpcClient { ); } - async listAttachments(request: ListAttachmentsRequest): Promise { + async listAttachments(request: ListAttachmentsRequest): Promise { return this.call( TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, ListAttachmentsRequestSchema, @@ -399,17 +414,17 @@ export class TeamsGrpcClient { ); } - async createAttachment(request: AttachmentCreateRequest): Promise { + async createAttachment(request: CreateAttachmentRequest): Promise { return this.call( TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - AttachmentCreateRequestSchema, + CreateAttachmentRequestSchema, request, 'createAttachment', ); } async deleteAttachment(request: DeleteAttachmentRequest): Promise { - await this.call( + await this.call( TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, DeleteAttachmentRequestSchema, request, diff --git a/proto/agynio/api/teams/v1/teams.proto b/proto/agynio/api/teams/v1/teams.proto deleted file mode 100644 index 573c3d09e..000000000 --- a/proto/agynio/api/teams/v1/teams.proto +++ /dev/null @@ -1,421 +0,0 @@ -syntax = "proto3"; - -package agynio.api.teams.v1; - -import "google/protobuf/empty.proto"; -import "google/protobuf/struct.proto"; -import "google/protobuf/timestamp.proto"; - -enum AgentWhenBusy { - AGENT_WHEN_BUSY_UNSPECIFIED = 0; - AGENT_WHEN_BUSY_WAIT = 1; - AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS = 2; -} - -enum AgentProcessBuffer { - AGENT_PROCESS_BUFFER_UNSPECIFIED = 0; - AGENT_PROCESS_BUFFER_ALL_TOGETHER = 1; - AGENT_PROCESS_BUFFER_ONE_BY_ONE = 2; -} - -enum ToolType { - TOOL_TYPE_UNSPECIFIED = 0; - TOOL_TYPE_MANAGE = 1; - TOOL_TYPE_MEMORY = 2; - TOOL_TYPE_SHELL_COMMAND = 3; - TOOL_TYPE_SEND_MESSAGE = 4; - TOOL_TYPE_SEND_SLACK_MESSAGE = 5; - TOOL_TYPE_REMIND_ME = 6; - TOOL_TYPE_GITHUB_CLONE_REPO = 7; - TOOL_TYPE_CALL_AGENT = 8; -} - -enum WorkspacePlatform { - WORKSPACE_PLATFORM_UNSPECIFIED = 0; - WORKSPACE_PLATFORM_LINUX_AMD64 = 1; - WORKSPACE_PLATFORM_LINUX_ARM64 = 2; - WORKSPACE_PLATFORM_AUTO = 3; -} - -enum MemoryBucketScope { - MEMORY_BUCKET_SCOPE_UNSPECIFIED = 0; - MEMORY_BUCKET_SCOPE_GLOBAL = 1; - MEMORY_BUCKET_SCOPE_PER_THREAD = 2; -} - -enum EntityType { - ENTITY_TYPE_UNSPECIFIED = 0; - ENTITY_TYPE_AGENT = 1; - ENTITY_TYPE_TOOL = 2; - ENTITY_TYPE_MCP_SERVER = 3; - ENTITY_TYPE_WORKSPACE_CONFIGURATION = 4; - ENTITY_TYPE_MEMORY_BUCKET = 5; -} - -enum AttachmentKind { - ATTACHMENT_KIND_UNSPECIFIED = 0; - ATTACHMENT_KIND_AGENT_TOOL = 1; - ATTACHMENT_KIND_AGENT_MEMORY_BUCKET = 2; - ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION = 3; - ATTACHMENT_KIND_AGENT_MCP_SERVER = 4; - ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION = 5; -} - -message AgentConfig { - string model = 1; - string system_prompt = 2; - uint32 debounce_ms = 3; - AgentWhenBusy when_busy = 4; - AgentProcessBuffer process_buffer = 5; - bool send_final_response_to_thread = 6; - uint32 summarization_keep_tokens = 7; - uint32 summarization_max_tokens = 8; - bool restrict_output = 9; - string restriction_message = 10; - uint32 restriction_max_injections = 11; - string name = 12; - string role = 13; -} - -message Agent { - string id = 1; - google.protobuf.Timestamp created_at = 2; - google.protobuf.Timestamp updated_at = 3; - string title = 4; - string description = 5; - AgentConfig config = 6; -} - -message AgentCreateRequest { - string title = 1; - string description = 2; - AgentConfig config = 3; -} - -message AgentUpdateRequest { - string id = 1; - optional string title = 2; - optional string description = 3; - AgentConfig config = 4; -} - -message GetAgentRequest { - string id = 1; -} - -message DeleteAgentRequest { - string id = 1; -} - -message ListAgentsRequest { - optional string q = 1; - optional uint32 page = 2; - optional uint32 per_page = 3; -} - -message PaginatedAgents { - repeated Agent items = 1; - uint32 page = 2; - uint32 per_page = 3; - uint64 total = 4; -} - -message Tool { - string id = 1; - google.protobuf.Timestamp created_at = 2; - google.protobuf.Timestamp updated_at = 3; - ToolType type = 4; - string name = 5; - string description = 6; - google.protobuf.Struct config = 7; -} - -message ToolCreateRequest { - ToolType type = 1; - string name = 2; - string description = 3; - google.protobuf.Struct config = 4; -} - -message ToolUpdateRequest { - string id = 1; - optional string name = 2; - optional string description = 3; - google.protobuf.Struct config = 4; -} - -message GetToolRequest { - string id = 1; -} - -message DeleteToolRequest { - string id = 1; -} - -message ListToolsRequest { - ToolType type = 1; - optional uint32 page = 2; - optional uint32 per_page = 3; -} - -message PaginatedTools { - repeated Tool items = 1; - uint32 page = 2; - uint32 per_page = 3; - uint64 total = 4; -} - -message McpEnvItem { - string name = 1; - string value = 2; -} - -message McpServerRestartPolicy { - uint32 max_attempts = 1; - uint32 backoff_ms = 2; -} - -message McpServerConfig { - string namespace = 1; - string command = 2; - string workdir = 3; - repeated McpEnvItem env = 4; - optional uint32 request_timeout_ms = 5; - optional uint32 startup_timeout_ms = 6; - optional uint32 heartbeat_interval_ms = 7; - optional uint32 stale_timeout_ms = 8; - McpServerRestartPolicy restart = 9; -} - -message McpServer { - string id = 1; - google.protobuf.Timestamp created_at = 2; - google.protobuf.Timestamp updated_at = 3; - string title = 4; - string description = 5; - McpServerConfig config = 6; -} - -message McpServerCreateRequest { - string title = 1; - string description = 2; - McpServerConfig config = 3; -} - -message McpServerUpdateRequest { - string id = 1; - optional string title = 2; - optional string description = 3; - McpServerConfig config = 4; -} - -message GetMcpServerRequest { - string id = 1; -} - -message DeleteMcpServerRequest { - string id = 1; -} - -message ListMcpServersRequest { - optional uint32 page = 1; - optional uint32 per_page = 2; -} - -message PaginatedMcpServers { - repeated McpServer items = 1; - uint32 page = 2; - uint32 per_page = 3; - uint64 total = 4; -} - -message WorkspaceEnvItem { - string name = 1; - string value = 2; -} - -message WorkspaceVolumeConfig { - bool enabled = 1; - string mount_path = 2; -} - -message WorkspaceConfig { - string image = 1; - repeated WorkspaceEnvItem env = 2; - string initial_script = 3; - google.protobuf.Value cpu_limit = 4; - google.protobuf.Value memory_limit = 5; - WorkspacePlatform platform = 6; - bool enable_dind = 7; - uint32 ttl_seconds = 8; - google.protobuf.Struct nix = 9; - WorkspaceVolumeConfig volumes = 10; -} - -message WorkspaceConfiguration { - string id = 1; - google.protobuf.Timestamp created_at = 2; - google.protobuf.Timestamp updated_at = 3; - string title = 4; - string description = 5; - WorkspaceConfig config = 6; -} - -message WorkspaceConfigurationCreateRequest { - string title = 1; - string description = 2; - WorkspaceConfig config = 3; -} - -message WorkspaceConfigurationUpdateRequest { - string id = 1; - optional string title = 2; - optional string description = 3; - WorkspaceConfig config = 4; -} - -message GetWorkspaceConfigurationRequest { - string id = 1; -} - -message DeleteWorkspaceConfigurationRequest { - string id = 1; -} - -message ListWorkspaceConfigurationsRequest { - optional uint32 page = 1; - optional uint32 per_page = 2; -} - -message PaginatedWorkspaceConfigurations { - repeated WorkspaceConfiguration items = 1; - uint32 page = 2; - uint32 per_page = 3; - uint64 total = 4; -} - -message MemoryBucketConfig { - MemoryBucketScope scope = 1; - string collection_prefix = 2; -} - -message MemoryBucket { - string id = 1; - google.protobuf.Timestamp created_at = 2; - google.protobuf.Timestamp updated_at = 3; - string title = 4; - string description = 5; - MemoryBucketConfig config = 6; -} - -message MemoryBucketCreateRequest { - string title = 1; - string description = 2; - MemoryBucketConfig config = 3; -} - -message MemoryBucketUpdateRequest { - string id = 1; - optional string title = 2; - optional string description = 3; - MemoryBucketConfig config = 4; -} - -message GetMemoryBucketRequest { - string id = 1; -} - -message DeleteMemoryBucketRequest { - string id = 1; -} - -message ListMemoryBucketsRequest { - optional uint32 page = 1; - optional uint32 per_page = 2; -} - -message PaginatedMemoryBuckets { - repeated MemoryBucket items = 1; - uint32 page = 2; - uint32 per_page = 3; - uint64 total = 4; -} - -message Attachment { - string id = 1; - google.protobuf.Timestamp created_at = 2; - google.protobuf.Timestamp updated_at = 3; - AttachmentKind kind = 4; - EntityType source_type = 5; - string source_id = 6; - EntityType target_type = 7; - string target_id = 8; -} - -message AttachmentCreateRequest { - AttachmentKind kind = 1; - string source_id = 2; - string target_id = 3; -} - -message DeleteAttachmentRequest { - string id = 1; -} - -message ListAttachmentsRequest { - EntityType source_type = 1; - optional string source_id = 2; - EntityType target_type = 3; - optional string target_id = 4; - AttachmentKind kind = 5; - optional uint32 page = 6; - optional uint32 per_page = 7; -} - -message PaginatedAttachments { - repeated Attachment items = 1; - uint32 page = 2; - uint32 per_page = 3; - uint64 total = 4; -} - -service TeamsService { - rpc ListAgents(ListAgentsRequest) returns (PaginatedAgents); - rpc CreateAgent(AgentCreateRequest) returns (Agent); - rpc GetAgent(GetAgentRequest) returns (Agent); - rpc UpdateAgent(AgentUpdateRequest) returns (Agent); - rpc DeleteAgent(DeleteAgentRequest) returns (google.protobuf.Empty); - - rpc ListTools(ListToolsRequest) returns (PaginatedTools); - rpc CreateTool(ToolCreateRequest) returns (Tool); - rpc GetTool(GetToolRequest) returns (Tool); - rpc UpdateTool(ToolUpdateRequest) returns (Tool); - rpc DeleteTool(DeleteToolRequest) returns (google.protobuf.Empty); - - rpc ListMcpServers(ListMcpServersRequest) returns (PaginatedMcpServers); - rpc CreateMcpServer(McpServerCreateRequest) returns (McpServer); - rpc GetMcpServer(GetMcpServerRequest) returns (McpServer); - rpc UpdateMcpServer(McpServerUpdateRequest) returns (McpServer); - rpc DeleteMcpServer(DeleteMcpServerRequest) returns (google.protobuf.Empty); - - rpc ListWorkspaceConfigurations(ListWorkspaceConfigurationsRequest) - returns (PaginatedWorkspaceConfigurations); - rpc CreateWorkspaceConfiguration(WorkspaceConfigurationCreateRequest) - returns (WorkspaceConfiguration); - rpc GetWorkspaceConfiguration(GetWorkspaceConfigurationRequest) - returns (WorkspaceConfiguration); - rpc UpdateWorkspaceConfiguration(WorkspaceConfigurationUpdateRequest) - returns (WorkspaceConfiguration); - rpc DeleteWorkspaceConfiguration(DeleteWorkspaceConfigurationRequest) - returns (google.protobuf.Empty); - - rpc ListMemoryBuckets(ListMemoryBucketsRequest) returns (PaginatedMemoryBuckets); - rpc CreateMemoryBucket(MemoryBucketCreateRequest) returns (MemoryBucket); - rpc GetMemoryBucket(GetMemoryBucketRequest) returns (MemoryBucket); - rpc UpdateMemoryBucket(MemoryBucketUpdateRequest) returns (MemoryBucket); - rpc DeleteMemoryBucket(DeleteMemoryBucketRequest) returns (google.protobuf.Empty); - - rpc ListAttachments(ListAttachmentsRequest) returns (PaginatedAttachments); - rpc CreateAttachment(AttachmentCreateRequest) returns (Attachment); - rpc DeleteAttachment(DeleteAttachmentRequest) returns (google.protobuf.Empty); -} diff --git a/proto/buf.yaml b/proto/buf.yaml deleted file mode 100644 index b8699818a..000000000 --- a/proto/buf.yaml +++ /dev/null @@ -1,3 +0,0 @@ -version: v2 -modules: - - path: . From 2d528bd71c7b0598d8ecbfb3459833a4c2d1e635 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 18:15:24 +0000 Subject: [PATCH 04/43] fix(proto): split teams grpc defs --- .../__tests__/teams/teamsGrpc.client.test.ts | 2 +- packages/platform-server/src/proto/grpc.ts | 248 +---------------- .../platform-server/src/proto/teams-grpc.ts | 261 ++++++++++++++++++ .../src/teams/teamsGrpc.client.ts | 2 +- 4 files changed, 267 insertions(+), 246 deletions(-) create mode 100644 packages/platform-server/src/proto/teams-grpc.ts diff --git a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts index a23d12948..3237e38d3 100644 --- a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -10,7 +10,7 @@ import { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; import { TEAMS_SERVICE_LIST_AGENTS_PATH, type TeamsServiceGrpcClientInstance, -} from '../../src/proto/grpc.js'; +} from '../../src/proto/teams-grpc.js'; const DEFAULT_ERROR_MESSAGE = 'Teams service request failed'; diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts index c952d0719..cd433e4cf 100644 --- a/packages/platform-server/src/proto/grpc.ts +++ b/packages/platform-server/src/proto/grpc.ts @@ -34,64 +34,6 @@ import { TouchWorkloadRequestSchema, TouchWorkloadResponseSchema, } from './gen/agynio/api/runner/v1/runner_pb.js'; -import { - CreateAgentRequestSchema, - CreateAgentResponseSchema, - CreateAttachmentRequestSchema, - CreateAttachmentResponseSchema, - CreateMcpServerRequestSchema, - CreateMcpServerResponseSchema, - CreateMemoryBucketRequestSchema, - CreateMemoryBucketResponseSchema, - CreateToolRequestSchema, - CreateToolResponseSchema, - CreateWorkspaceConfigurationRequestSchema, - CreateWorkspaceConfigurationResponseSchema, - DeleteAgentRequestSchema, - DeleteAgentResponseSchema, - DeleteAttachmentRequestSchema, - DeleteAttachmentResponseSchema, - DeleteMcpServerRequestSchema, - DeleteMcpServerResponseSchema, - DeleteMemoryBucketRequestSchema, - DeleteMemoryBucketResponseSchema, - DeleteToolRequestSchema, - DeleteToolResponseSchema, - DeleteWorkspaceConfigurationRequestSchema, - DeleteWorkspaceConfigurationResponseSchema, - GetAgentRequestSchema, - GetAgentResponseSchema, - GetMcpServerRequestSchema, - GetMcpServerResponseSchema, - GetMemoryBucketRequestSchema, - GetMemoryBucketResponseSchema, - GetToolRequestSchema, - GetToolResponseSchema, - GetWorkspaceConfigurationRequestSchema, - GetWorkspaceConfigurationResponseSchema, - ListAgentsRequestSchema, - ListAgentsResponseSchema, - ListAttachmentsRequestSchema, - ListAttachmentsResponseSchema, - ListMcpServersRequestSchema, - ListMcpServersResponseSchema, - ListMemoryBucketsRequestSchema, - ListMemoryBucketsResponseSchema, - ListToolsRequestSchema, - ListToolsResponseSchema, - ListWorkspaceConfigurationsRequestSchema, - ListWorkspaceConfigurationsResponseSchema, - UpdateAgentRequestSchema, - UpdateAgentResponseSchema, - UpdateMcpServerRequestSchema, - UpdateMcpServerResponseSchema, - UpdateMemoryBucketRequestSchema, - UpdateMemoryBucketResponseSchema, - UpdateToolRequestSchema, - UpdateToolResponseSchema, - UpdateWorkspaceConfigurationRequestSchema, - UpdateWorkspaceConfigurationResponseSchema, -} from './gen/agynio/api/teams/v1/teams_pb.js'; const unaryDefinition = ( path: string, @@ -144,8 +86,10 @@ export const RUNNER_SERVICE_STOP_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerSe export const RUNNER_SERVICE_REMOVE_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/RemoveWorkload'; export const RUNNER_SERVICE_INSPECT_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/InspectWorkload'; export const RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/GetWorkloadLabels'; -export const RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/FindWorkloadsByLabels'; -export const RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/ListWorkloadsByVolume'; +export const RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH = + '/agynio.api.runner.v1.RunnerService/FindWorkloadsByLabels'; +export const RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH = + '/agynio.api.runner.v1.RunnerService/ListWorkloadsByVolume'; export const RUNNER_SERVICE_REMOVE_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/RemoveVolume'; export const RUNNER_SERVICE_TOUCH_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/TouchWorkload'; export const RUNNER_SERVICE_PUT_ARCHIVE_PATH = '/agynio.api.runner.v1.RunnerService/PutArchive'; @@ -238,187 +182,3 @@ export const RunnerServiceGrpcClient = makeGenericClientConstructor( ); export type RunnerServiceGrpcClientInstance = InstanceType; - -export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; -export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; -export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; -export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; -export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; -export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; -export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; -export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; -export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; -export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; -export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; -export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; -export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; -export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; -export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; -export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = - '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; -export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; -export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; -export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; -export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; -export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; -export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; -export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; -export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; -export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; -export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; -export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; -export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; - -export const teamsServiceGrpcDefinition: ServiceDefinition = { - listAgents: unaryDefinition( - TEAMS_SERVICE_LIST_AGENTS_PATH, - ListAgentsRequestSchema, - ListAgentsResponseSchema, - ), - createAgent: unaryDefinition( - TEAMS_SERVICE_CREATE_AGENT_PATH, - CreateAgentRequestSchema, - CreateAgentResponseSchema, - ), - getAgent: unaryDefinition( - TEAMS_SERVICE_GET_AGENT_PATH, - GetAgentRequestSchema, - GetAgentResponseSchema, - ), - updateAgent: unaryDefinition( - TEAMS_SERVICE_UPDATE_AGENT_PATH, - UpdateAgentRequestSchema, - UpdateAgentResponseSchema, - ), - deleteAgent: unaryDefinition( - TEAMS_SERVICE_DELETE_AGENT_PATH, - DeleteAgentRequestSchema, - DeleteAgentResponseSchema, - ), - listTools: unaryDefinition( - TEAMS_SERVICE_LIST_TOOLS_PATH, - ListToolsRequestSchema, - ListToolsResponseSchema, - ), - createTool: unaryDefinition( - TEAMS_SERVICE_CREATE_TOOL_PATH, - CreateToolRequestSchema, - CreateToolResponseSchema, - ), - getTool: unaryDefinition( - TEAMS_SERVICE_GET_TOOL_PATH, - GetToolRequestSchema, - GetToolResponseSchema, - ), - updateTool: unaryDefinition( - TEAMS_SERVICE_UPDATE_TOOL_PATH, - UpdateToolRequestSchema, - UpdateToolResponseSchema, - ), - deleteTool: unaryDefinition( - TEAMS_SERVICE_DELETE_TOOL_PATH, - DeleteToolRequestSchema, - DeleteToolResponseSchema, - ), - listMcpServers: unaryDefinition( - TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, - ListMcpServersRequestSchema, - ListMcpServersResponseSchema, - ), - createMcpServer: unaryDefinition( - TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - CreateMcpServerRequestSchema, - CreateMcpServerResponseSchema, - ), - getMcpServer: unaryDefinition( - TEAMS_SERVICE_GET_MCP_SERVER_PATH, - GetMcpServerRequestSchema, - GetMcpServerResponseSchema, - ), - updateMcpServer: unaryDefinition( - TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - UpdateMcpServerRequestSchema, - UpdateMcpServerResponseSchema, - ), - deleteMcpServer: unaryDefinition( - TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, - DeleteMcpServerRequestSchema, - DeleteMcpServerResponseSchema, - ), - listWorkspaceConfigurations: unaryDefinition( - TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, - ListWorkspaceConfigurationsRequestSchema, - ListWorkspaceConfigurationsResponseSchema, - ), - createWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - CreateWorkspaceConfigurationRequestSchema, - CreateWorkspaceConfigurationResponseSchema, - ), - getWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, - GetWorkspaceConfigurationRequestSchema, - GetWorkspaceConfigurationResponseSchema, - ), - updateWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, - UpdateWorkspaceConfigurationRequestSchema, - UpdateWorkspaceConfigurationResponseSchema, - ), - deleteWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, - DeleteWorkspaceConfigurationRequestSchema, - DeleteWorkspaceConfigurationResponseSchema, - ), - listMemoryBuckets: unaryDefinition( - TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, - ListMemoryBucketsRequestSchema, - ListMemoryBucketsResponseSchema, - ), - createMemoryBucket: unaryDefinition( - TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - CreateMemoryBucketRequestSchema, - CreateMemoryBucketResponseSchema, - ), - getMemoryBucket: unaryDefinition( - TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, - GetMemoryBucketRequestSchema, - GetMemoryBucketResponseSchema, - ), - updateMemoryBucket: unaryDefinition( - TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - UpdateMemoryBucketRequestSchema, - UpdateMemoryBucketResponseSchema, - ), - deleteMemoryBucket: unaryDefinition( - TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, - DeleteMemoryBucketRequestSchema, - DeleteMemoryBucketResponseSchema, - ), - listAttachments: unaryDefinition( - TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, - ListAttachmentsRequestSchema, - ListAttachmentsResponseSchema, - ), - createAttachment: unaryDefinition( - TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - CreateAttachmentRequestSchema, - CreateAttachmentResponseSchema, - ), - deleteAttachment: unaryDefinition( - TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, - DeleteAttachmentRequestSchema, - DeleteAttachmentResponseSchema, - ), -}; - -export const TeamsServiceGrpcClient = makeGenericClientConstructor( - teamsServiceGrpcDefinition, - 'agynio.api.teams.v1.TeamsService', -); - -export type TeamsServiceGrpcClientInstance = InstanceType; diff --git a/packages/platform-server/src/proto/teams-grpc.ts b/packages/platform-server/src/proto/teams-grpc.ts new file mode 100644 index 000000000..3c67625bb --- /dev/null +++ b/packages/platform-server/src/proto/teams-grpc.ts @@ -0,0 +1,261 @@ +import { makeGenericClientConstructor } from '@grpc/grpc-js'; +import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; +import { toBinary, fromBinary } from '@bufbuild/protobuf'; +import type { DescMessage } from '@bufbuild/protobuf'; +import { + CreateAgentRequestSchema, + CreateAgentResponseSchema, + CreateAttachmentRequestSchema, + CreateAttachmentResponseSchema, + CreateMcpServerRequestSchema, + CreateMcpServerResponseSchema, + CreateMemoryBucketRequestSchema, + CreateMemoryBucketResponseSchema, + CreateToolRequestSchema, + CreateToolResponseSchema, + CreateWorkspaceConfigurationRequestSchema, + CreateWorkspaceConfigurationResponseSchema, + DeleteAgentRequestSchema, + DeleteAgentResponseSchema, + DeleteAttachmentRequestSchema, + DeleteAttachmentResponseSchema, + DeleteMcpServerRequestSchema, + DeleteMcpServerResponseSchema, + DeleteMemoryBucketRequestSchema, + DeleteMemoryBucketResponseSchema, + DeleteToolRequestSchema, + DeleteToolResponseSchema, + DeleteWorkspaceConfigurationRequestSchema, + DeleteWorkspaceConfigurationResponseSchema, + GetAgentRequestSchema, + GetAgentResponseSchema, + GetMcpServerRequestSchema, + GetMcpServerResponseSchema, + GetMemoryBucketRequestSchema, + GetMemoryBucketResponseSchema, + GetToolRequestSchema, + GetToolResponseSchema, + GetWorkspaceConfigurationRequestSchema, + GetWorkspaceConfigurationResponseSchema, + ListAgentsRequestSchema, + ListAgentsResponseSchema, + ListAttachmentsRequestSchema, + ListAttachmentsResponseSchema, + ListMcpServersRequestSchema, + ListMcpServersResponseSchema, + ListMemoryBucketsRequestSchema, + ListMemoryBucketsResponseSchema, + ListToolsRequestSchema, + ListToolsResponseSchema, + ListWorkspaceConfigurationsRequestSchema, + ListWorkspaceConfigurationsResponseSchema, + UpdateAgentRequestSchema, + UpdateAgentResponseSchema, + UpdateMcpServerRequestSchema, + UpdateMcpServerResponseSchema, + UpdateMemoryBucketRequestSchema, + UpdateMemoryBucketResponseSchema, + UpdateToolRequestSchema, + UpdateToolResponseSchema, + UpdateWorkspaceConfigurationRequestSchema, + UpdateWorkspaceConfigurationResponseSchema, +} from './gen/agynio/api/teams/v1/teams_pb.js'; + +const unaryDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: false, + responseStream: false, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; +export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; +export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; +export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; +export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; +export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; +export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; +export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; +export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; +export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; +export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; +export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; +export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; +export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; +export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; +export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = + '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; +export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; +export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; +export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; +export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; +export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; +export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; +export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; +export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; +export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; +export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; +export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; +export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; + +export const teamsServiceGrpcDefinition: ServiceDefinition = { + listAgents: unaryDefinition( + TEAMS_SERVICE_LIST_AGENTS_PATH, + ListAgentsRequestSchema, + ListAgentsResponseSchema, + ), + createAgent: unaryDefinition( + TEAMS_SERVICE_CREATE_AGENT_PATH, + CreateAgentRequestSchema, + CreateAgentResponseSchema, + ), + getAgent: unaryDefinition( + TEAMS_SERVICE_GET_AGENT_PATH, + GetAgentRequestSchema, + GetAgentResponseSchema, + ), + updateAgent: unaryDefinition( + TEAMS_SERVICE_UPDATE_AGENT_PATH, + UpdateAgentRequestSchema, + UpdateAgentResponseSchema, + ), + deleteAgent: unaryDefinition( + TEAMS_SERVICE_DELETE_AGENT_PATH, + DeleteAgentRequestSchema, + DeleteAgentResponseSchema, + ), + listTools: unaryDefinition( + TEAMS_SERVICE_LIST_TOOLS_PATH, + ListToolsRequestSchema, + ListToolsResponseSchema, + ), + createTool: unaryDefinition( + TEAMS_SERVICE_CREATE_TOOL_PATH, + CreateToolRequestSchema, + CreateToolResponseSchema, + ), + getTool: unaryDefinition( + TEAMS_SERVICE_GET_TOOL_PATH, + GetToolRequestSchema, + GetToolResponseSchema, + ), + updateTool: unaryDefinition( + TEAMS_SERVICE_UPDATE_TOOL_PATH, + UpdateToolRequestSchema, + UpdateToolResponseSchema, + ), + deleteTool: unaryDefinition( + TEAMS_SERVICE_DELETE_TOOL_PATH, + DeleteToolRequestSchema, + DeleteToolResponseSchema, + ), + listMcpServers: unaryDefinition( + TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + ListMcpServersRequestSchema, + ListMcpServersResponseSchema, + ), + createMcpServer: unaryDefinition( + TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + CreateMcpServerRequestSchema, + CreateMcpServerResponseSchema, + ), + getMcpServer: unaryDefinition( + TEAMS_SERVICE_GET_MCP_SERVER_PATH, + GetMcpServerRequestSchema, + GetMcpServerResponseSchema, + ), + updateMcpServer: unaryDefinition( + TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + UpdateMcpServerRequestSchema, + UpdateMcpServerResponseSchema, + ), + deleteMcpServer: unaryDefinition( + TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + DeleteMcpServerRequestSchema, + DeleteMcpServerResponseSchema, + ), + listWorkspaceConfigurations: unaryDefinition( + TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + ListWorkspaceConfigurationsRequestSchema, + ListWorkspaceConfigurationsResponseSchema, + ), + createWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + CreateWorkspaceConfigurationRequestSchema, + CreateWorkspaceConfigurationResponseSchema, + ), + getWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + GetWorkspaceConfigurationRequestSchema, + GetWorkspaceConfigurationResponseSchema, + ), + updateWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, + UpdateWorkspaceConfigurationRequestSchema, + UpdateWorkspaceConfigurationResponseSchema, + ), + deleteWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + DeleteWorkspaceConfigurationRequestSchema, + DeleteWorkspaceConfigurationResponseSchema, + ), + listMemoryBuckets: unaryDefinition( + TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + ListMemoryBucketsRequestSchema, + ListMemoryBucketsResponseSchema, + ), + createMemoryBucket: unaryDefinition( + TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + CreateMemoryBucketRequestSchema, + CreateMemoryBucketResponseSchema, + ), + getMemoryBucket: unaryDefinition( + TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + GetMemoryBucketRequestSchema, + GetMemoryBucketResponseSchema, + ), + updateMemoryBucket: unaryDefinition( + TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + UpdateMemoryBucketRequestSchema, + UpdateMemoryBucketResponseSchema, + ), + deleteMemoryBucket: unaryDefinition( + TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + DeleteMemoryBucketRequestSchema, + DeleteMemoryBucketResponseSchema, + ), + listAttachments: unaryDefinition( + TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + ListAttachmentsRequestSchema, + ListAttachmentsResponseSchema, + ), + createAttachment: unaryDefinition( + TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + CreateAttachmentRequestSchema, + CreateAttachmentResponseSchema, + ), + deleteAttachment: unaryDefinition( + TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + DeleteAttachmentRequestSchema, + DeleteAttachmentResponseSchema, + ), +}; + +export const TeamsServiceGrpcClient = makeGenericClientConstructor( + teamsServiceGrpcDefinition, + 'agynio.api.teams.v1.TeamsService', +); + +export type TeamsServiceGrpcClientInstance = InstanceType; diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts index c44e24fe4..47e197364 100644 --- a/packages/platform-server/src/teams/teamsGrpc.client.ts +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -120,7 +120,7 @@ import { TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, TEAMS_SERVICE_UPDATE_TOOL_PATH, TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, -} from '../proto/grpc.js'; +} from '../proto/teams-grpc.js'; type TeamsGrpcClientConfig = { address: string; From 333bdba93a37176687aa43e06d2c2a25c96973cc Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 11 Mar 2026 05:17:05 +0000 Subject: [PATCH 05/43] refactor(grpc): migrate runner clients --- buf.gen.yaml | 4 + .../containers.docker.integration.test.ts | 213 +++ packages/docker-runner/package.json | 33 + .../src/lib/container.service.ts | 1075 +++++++++++++++ .../docker-runner/src/service/grpc/server.ts | 1226 +++++++++++++++++ packages/docker-runner/src/service/main.ts | 64 + .../containers.delete.integration.test.ts | 28 +- .../__tests__/helpers/docker.e2e.ts | 222 ++- .../__tests__/infra/runnerGrpc.client.test.ts | 133 +- ...ec.cancellation.docker.integration.test.ts | 16 +- .../__tests__/teams/teamsGrpc.client.test.ts | 172 +-- .../__tests__/terminal.gateway.test.ts | 4 + .../__tests__/workspace.exec.grpc.test.ts | 105 +- .../workspace.reuse.integration.test.ts | 18 +- packages/platform-server/package.json | 10 +- .../src/infra/container/dockerRunner.types.ts | 6 + .../src/infra/container/runnerGrpc.client.ts | 1009 +++++++------- packages/platform-server/src/proto/grpc.ts | 184 --- .../platform-server/src/proto/teams-grpc.ts | 261 ---- .../src/teams/teamsGrpc.client.ts | 243 ++-- pnpm-lock.yaml | 277 +++- 21 files changed, 3964 insertions(+), 1339 deletions(-) create mode 100644 packages/docker-runner/__tests__/containers.docker.integration.test.ts create mode 100644 packages/docker-runner/package.json create mode 100644 packages/docker-runner/src/lib/container.service.ts create mode 100644 packages/docker-runner/src/service/grpc/server.ts create mode 100644 packages/docker-runner/src/service/main.ts delete mode 100644 packages/platform-server/src/proto/grpc.ts delete mode 100644 packages/platform-server/src/proto/teams-grpc.ts diff --git a/buf.gen.yaml b/buf.gen.yaml index 5a8a9e5e2..8d85d3147 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -8,3 +8,7 @@ plugins: out: packages/platform-server/src/proto/gen opt: - target=ts + - plugin: buf.build/bufbuild/es + out: packages/docker-runner/src/proto/gen + opt: + - target=ts diff --git a/packages/docker-runner/__tests__/containers.docker.integration.test.ts b/packages/docker-runner/__tests__/containers.docker.integration.test.ts new file mode 100644 index 000000000..52ae633eb --- /dev/null +++ b/packages/docker-runner/__tests__/containers.docker.integration.test.ts @@ -0,0 +1,213 @@ +import fs from 'node:fs'; +import { randomUUID } from 'node:crypto'; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { create } from '@bufbuild/protobuf'; +import { Code, createClient, type Client, type Interceptor } from '@connectrpc/connect'; +import { createGrpcTransport, Http2SessionManager } from '@connectrpc/connect-node'; +import type { Http2Server } from 'node:http2'; + +import type { RunnerConfig } from '../src/service/config'; +import { ContainerService, NonceCache } from '../src'; +import { buildAuthHeaders } from '../src/contracts/auth'; +import { createRunnerGrpcServer } from '../src/service/grpc/server'; +import { + InspectWorkloadRequestSchema, + ReadyRequestSchema, + RemoveWorkloadRequestSchema, + StopWorkloadRequestSchema, + RunnerService, + type InspectWorkloadResponse, + type StartWorkloadResponse, +} from '../src/proto/gen/agynio/api/runner/v1/runner_pb.js'; +import { containerOptsToStartWorkloadRequest } from '../src/contracts/workload.grpc'; + +const DEFAULT_SOCKET = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; +const hasSocket = fs.existsSync(DEFAULT_SOCKET); +const hasDockerHost = Boolean(process.env.DOCKER_HOST); +const shouldSkip = process.env.SKIP_DOCKER_RUNNER_E2E === '1' || (!hasSocket && !hasDockerHost); + +const describeOrSkip = shouldSkip ? describe.skip : describe; + +if (shouldSkip) { + const reason = process.env.SKIP_DOCKER_RUNNER_E2E === '1' + ? 'SKIP_DOCKER_RUNNER_E2E was explicitly set' + : 'No Docker socket found and DOCKER_HOST is not defined'; + console.warn(`Skipping docker-runner docker-backed integration tests: ${reason}`); +} + +const RUNNER_SECRET = 'docker-runner-integration-secret'; +type RunnerServiceClient = Client; + +describeOrSkip('docker-runner docker-backed container lifecycle', () => { + let grpcAddress: string; + let client: RunnerServiceClient; + let shutdown: (() => Promise) | null = null; + let sessionManager: Http2SessionManager | null = null; + const startedContainers = new Set(); + + beforeAll(async () => { + const config: RunnerConfig = { + sharedSecret: RUNNER_SECRET, + signatureTtlMs: 60_000, + dockerSocket: hasSocket ? DEFAULT_SOCKET : '', + logLevel: 'error', + grpcHost: '127.0.0.1', + grpcPort: 0, + }; + const nonceCache = new NonceCache({ ttlMs: config.signatureTtlMs }); + const previousSocket = process.env.DOCKER_SOCKET; + if (config.dockerSocket) { + process.env.DOCKER_SOCKET = config.dockerSocket; + } + const containers = new ContainerService(); + const server = createRunnerGrpcServer({ config, containers, nonceCache }); + const address = await bindServer(server, config.grpcHost); + grpcAddress = address; + const baseUrl = `http://${grpcAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + const transport = createGrpcTransport({ + baseUrl, + sessionManager, + interceptors: [createRunnerAuthInterceptor(RUNNER_SECRET)], + }); + client = createClient(RunnerService, transport); + await waitForReady(); + shutdown = async () => { + sessionManager?.abort(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + sessionManager = null; + if (previousSocket !== undefined) { + process.env.DOCKER_SOCKET = previousSocket; + } else { + delete process.env.DOCKER_SOCKET; + } + }; + }, 30_000); + + afterAll(async () => { + if (shutdown) { + await shutdown(); + shutdown = null; + } + }); + + afterEach(async () => { + for (const containerId of startedContainers) { + try { + await stopContainer(containerId); + } catch (error) { + console.warn(`cleanup stop failed for ${containerId}`, error); + } + try { + await removeContainer(containerId, { force: true, removeVolumes: true }); + } catch (error) { + console.warn(`cleanup remove failed for ${containerId}`, error); + } + } + startedContainers.clear(); + }); + + it('starts, inspects, stops, and removes a real container', async () => { + const containerId = await startAlpineContainer('delete-once'); + + const inspect = await inspectContainer(containerId); + expect(inspect.id).toBe(containerId); + expect(inspect.configImage).toContain('alpine'); + + await deleteContainer(containerId); + + await expect(inspectContainer(containerId)).rejects.toMatchObject({ code: Code.NotFound }); + }, 120_000); + + it('allows delete operations to be invoked twice without failing', async () => { + const containerId = await startAlpineContainer('delete-twice'); + + await deleteContainer(containerId); + await expect(stopContainer(containerId)).resolves.toBeUndefined(); + await expect(removeContainer(containerId, { force: true, removeVolumes: true })).resolves.toBeUndefined(); + }, 120_000); + + async function startAlpineContainer(prefix: string): Promise { + const name = `${prefix}-${randomUUID().slice(0, 8)}`; + const response = await startWorkload({ image: 'alpine:3.19', cmd: ['sleep', '30'], name, autoRemove: false }); + if (!response?.containers?.main && !response?.id) { + throw new Error('runner start did not return containerId'); + } + const containerId = response.containers?.main ?? response.id; + startedContainers.add(containerId); + return containerId; + } + + async function deleteContainer(containerId: string): Promise { + await stopContainer(containerId); + await removeContainer(containerId, { force: true, removeVolumes: true }); + startedContainers.delete(containerId); + } + + async function startWorkload(opts: { image: string; cmd: string[]; name: string; autoRemove: boolean }): Promise { + const request = containerOptsToStartWorkloadRequest({ + image: opts.image, + cmd: opts.cmd, + name: opts.name, + autoRemove: opts.autoRemove, + }); + return client.startWorkload(request); + } + + async function stopContainer(containerId: string) { + const request = create(StopWorkloadRequestSchema, { workloadId: containerId, timeoutSec: 1 }); + await client.stopWorkload(request); + } + + async function removeContainer(containerId: string, options: { force?: boolean; removeVolumes?: boolean }) { + const request = create(RemoveWorkloadRequestSchema, { + workloadId: containerId, + force: options.force ?? false, + removeVolumes: options.removeVolumes ?? false, + }); + await client.removeWorkload(request); + } + + async function inspectContainer(containerId: string): Promise { + const request = create(InspectWorkloadRequestSchema, { workloadId: containerId }); + return client.inspectWorkload(request); + } + + async function waitForReady(): Promise { + const request = create(ReadyRequestSchema, {}); + await client.ready(request); + } +}); + +function createRunnerAuthInterceptor(secret: string): Interceptor { + return (next) => async (req) => { + const path = new URL(req.url).pathname; + const headers = buildAuthHeaders({ method: req.requestMethod, path, body: '', secret }); + for (const [key, value] of Object.entries(headers)) { + req.header.set(key, value); + } + return next(req); + }; +} + +async function bindServer(server: Http2Server, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.off('error', onError); + reject(err); + }; + server.once('error', onError); + server.listen(0, host, () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind docker-runner server')); + return; + } + resolve(`${host}:${address.port}`); + }); + }); +} diff --git a/packages/docker-runner/package.json b/packages/docker-runner/package.json new file mode 100644 index 000000000..21dbcddba --- /dev/null +++ b/packages/docker-runner/package.json @@ -0,0 +1,33 @@ +{ + "name": "@agyn/docker-runner", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "scripts": { + "dev": "tsx src/service/main.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/service/main.js", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.11.0", + "@connectrpc/connect": "^2.1.1", + "@connectrpc/connect-node": "^2.1.1", + "@nestjs/common": "^11.1.7", + "dockerode": "^4.0.8", + "dotenv": "^17.2.2", + "uuid": "^13.0.0", + "zod": "^4.1.9" + }, + "devDependencies": { + "@types/dockerode": "^3.3.44", + "@types/node": "^24.5.1", + "eslint": "^9.13.0", + "tsx": "^4.20.5", + "typescript": "^5.8.3", + "typescript-eslint": "^8.8.1", + "vitest": "^3.2.4" + } +} diff --git a/packages/docker-runner/src/lib/container.service.ts b/packages/docker-runner/src/lib/container.service.ts new file mode 100644 index 000000000..ad12b5948 --- /dev/null +++ b/packages/docker-runner/src/lib/container.service.ts @@ -0,0 +1,1075 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Docker, { ContainerCreateOptions, Exec, type GetEventsOptions } from 'dockerode'; +import { PassThrough, Writable } from 'node:stream'; +import { ContainerHandle } from './container.handle'; +import { mapInspectMounts } from './container.mounts'; +import { createUtf8Collector, demuxDockerMultiplex } from './containerStream.util'; +import { ExecIdleTimeoutError, ExecTimeoutError, isExecIdleTimeoutError, isExecTimeoutError } from './execTimeout'; +import type { ContainerRegistryPort } from './containerRegistry.port'; +import type { DockerClientPort } from './dockerClient.port'; +import { + ContainerOpts, + ExecOptions, + ExecResult, + ExecInspectInfo, + InteractiveExecOptions, + InteractiveExecSession, + LogsStreamOptions, + LogsStreamSession, + Platform, + PLATFORM_LABEL, + type ContainerInspectInfo, +} from './types'; + +const INTERACTIVE_EXEC_CLOSE_CAPTURE_LIMIT = 256 * 1024; // 256 KiB of characters (~512 KiB memory) + +const DEFAULT_IMAGE = 'mcr.microsoft.com/vscode/devcontainers/base'; + +/** + * ContainerService provides a thin wrapper around dockerode for: + * - Ensuring (pulling) images + * - Creating & starting containers + * - Executing commands inside running containers (capturing stdout/stderr) + * - Stopping & removing containers + * + * This intentionally avoids opinionated higher-level orchestration so it can be + * used flexibly by tools/agents. All methods log their high-level actions. + * + * Usage example: + * const svc = new ContainerService(containerRegistry, logger); + * const c = await svc.start({ image: "node:20-alpine", cmd: ["sleep", "3600"], autoRemove: true }); + * const result = await c.exec("node -v"); + * await c.stop(); + * await c.remove(); + */ +@Injectable() +export class ContainerService implements DockerClientPort { + private readonly logger = new Logger(ContainerService.name); + private docker: Docker; + constructor(private readonly registry?: ContainerRegistryPort | null) { + this.docker = new Docker({ + ...(process.env.DOCKER_SOCKET + ? { + socketPath: process.env.DOCKER_SOCKET, + } + : {}), + }); + } + + private format(context?: Record): string { + return context ? ` ${JSON.stringify(context)}` : ''; + } + + private log(message: string, context?: Record): void { + this.logger.log(`${message}${this.format(context)}`); + } + + private debug(message: string, context?: Record): void { + this.logger.debug(`${message}${this.format(context)}`); + } + + private warn(message: string, context?: Record): void { + this.logger.warn(`${message}${this.format(context)}`); + } + + private error(message: string, context?: Record): void { + this.logger.error(`${message}${this.format(context)}`); + } + + private errorContext(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { error }; + } + + /** Public helper to touch last-used timestamp for a container */ + async touchLastUsed(containerId: string): Promise { + try { + await this.registry?.updateLastUsed(containerId, new Date()); + } catch (e: unknown) { + const msg = e && typeof e === 'object' && 'message' in e ? String((e as Error).message) : String(e); + this.debug(`touchLastUsed failed for cid=${containerId.substring(0, 12)} ${msg}`); + } + } + + /** Pull an image; if platform is specified, pull even when image exists to ensure correct arch. */ + async ensureImage(image: string, platform?: Platform): Promise { + this.log(`Ensuring image '${image}' is available locally`); + // Check if image exists + try { + await this.docker.getImage(image).inspect(); + this.debug(`Image '${image}' already present`); + // When platform is provided, still pull to ensure the desired arch variant is present. + if (!platform) return; + } catch { + this.log(`Image '${image}' not found locally. Pulling...`); + } + + await new Promise((resolve, reject) => { + type PullOpts = { platform?: string }; + const cb = (err: Error | undefined, stream?: NodeJS.ReadableStream) => { + if (err) return reject(err); + if (!stream) return reject(new Error('No pull stream returned')); + this.docker.modem.followProgress( + stream, + (doneErr?: unknown) => { + if (doneErr) return reject(doneErr); + this.log(`Finished pulling image '${image}'`); + resolve(); + }, + (event: { status?: string; id?: string }) => { + if (event?.status && event?.id) { + this.debug(`${event.id}: ${event.status}`); + } else if (event?.status) { + this.debug(event.status); + } + }, + ); + }; + // Use overloads: with opts when platform provided, otherwise variant without opts + if (platform) this.docker.pull(image, { platform } as PullOpts, cb); + else this.docker.pull(image, cb); + }); + } + + /** + * Start a new container and return a ContainerHandle representing it. + */ + async start(opts?: ContainerOpts): Promise { + const defaults: Partial = { image: DEFAULT_IMAGE, autoRemove: true, tty: false }; + const optsWithDefaults = { ...defaults, ...opts }; + + await this.ensureImage(optsWithDefaults.image!, optsWithDefaults.platform); + + const Env: string[] | undefined = Array.isArray(optsWithDefaults.env) + ? optsWithDefaults.env + : optsWithDefaults.env + ? Object.entries(optsWithDefaults.env).map(([k, v]) => `${k}=${v}`) + : undefined; + + // dockerode forwards unknown top-level options (e.g., name, platform) as query params + type CreateOptsWithPlatform = ContainerCreateOptions & { name?: string; platform?: string }; + const createOptions: CreateOptsWithPlatform = { + Image: optsWithDefaults.image, + name: optsWithDefaults.name, + platform: optsWithDefaults.platform, + Cmd: optsWithDefaults.cmd, + Env, + WorkingDir: optsWithDefaults.workingDir, + HostConfig: { + AutoRemove: optsWithDefaults.autoRemove ?? false, + Binds: optsWithDefaults.binds, + NetworkMode: optsWithDefaults.networkMode, + Privileged: optsWithDefaults.privileged ?? false, + }, + Volumes: + optsWithDefaults.anonymousVolumes && optsWithDefaults.anonymousVolumes.length > 0 + ? Object.fromEntries(optsWithDefaults.anonymousVolumes.map((p) => [p, {} as Record])) + : undefined, + Tty: optsWithDefaults.tty ?? false, + AttachStdout: true, + AttachStderr: true, + Labels: { + ...(optsWithDefaults.labels || {}), + ...(optsWithDefaults.platform ? { [PLATFORM_LABEL]: optsWithDefaults.platform } : {}), + }, + }; + + // Merge createExtras last (shallow, with nested HostConfig merged shallowly as well) + if (optsWithDefaults.createExtras) { + const extras: Partial = optsWithDefaults.createExtras; + if (extras.HostConfig) { + createOptions.HostConfig = { ...(createOptions.HostConfig || {}), ...extras.HostConfig }; + } + const { HostConfig: _hc, ...rest } = extras; + Object.assign(createOptions, rest); + } + + this.log( + `Creating container from '${optsWithDefaults.image}'${optsWithDefaults.name ? ` name=${optsWithDefaults.name}` : ''}`, + ); + const container = await this.docker.createContainer(createOptions); + await container.start(); + const inspect = await container.inspect(); + this.log(`Container started cid=${inspect.Id.substring(0, 12)} status=${inspect.State?.Status}`); + // Persist container start in registry (workspace and DinD) + if (this.registry) { + try { + const labels = inspect.Config?.Labels || {}; + const nodeId = labels['hautech.ai/node_id'] || 'unknown'; + const threadId = labels['hautech.ai/thread_id'] || ''; + const mounts = mapInspectMounts(inspect.Mounts); + const inspectNameRaw = typeof inspect.Name === 'string' ? inspect.Name : null; + const normalizedInspectName = inspectNameRaw?.trim().replace(/^\/+/, '') ?? null; + const fallbackName = optsWithDefaults.name?.trim() || inspect.Id.substring(0, 63); + const resolvedName = (normalizedInspectName && normalizedInspectName.length > 0 + ? normalizedInspectName + : fallbackName + ).slice(0, 63); + await this.registry.registerStart({ + containerId: inspect.Id, + nodeId, + threadId, + image: optsWithDefaults.image!, + providerType: 'docker', + labels, + platform: optsWithDefaults.platform, + ttlSeconds: optsWithDefaults.ttlSeconds, + mounts: mounts.length ? mounts : undefined, + name: resolvedName, + }); + } catch (e) { + this.error('Failed to register container start', { error: this.errorContext(e) }); + } + } + return new ContainerHandle(this, inspect.Id); + } + + /** Execute a command inside a running container by its docker id. */ + async execContainer(containerId: string, command: string[] | string, options?: ExecOptions): Promise { + const container = this.docker.getContainer(containerId); + const inspectData = await container.inspect(); + if (inspectData.State?.Running !== true) { + throw new Error(`Container '${containerId}' is not running`); + } + + const logToPid1 = options?.logToPid1 ?? false; + const Cmd = logToPid1 + ? this.buildLogToPid1Command(command) + : Array.isArray(command) + ? command + : ['/bin/sh', '-lc', command]; + const Env: string[] | undefined = Array.isArray(options?.env) + ? options?.env + : options?.env + ? Object.entries(options.env).map(([k, v]) => `${k}=${v}`) + : undefined; + + this.debug( + `Exec in container cid=${inspectData.Id.substring(0, 12)} logToPid1=${logToPid1}: ${Cmd.join(' ')}`, + ); + // Update last-used before starting exec + void this.touchLastUsed(inspectData.Id); + const exec: Exec = await container.exec({ + Cmd, + AttachStdout: true, + AttachStderr: true, + WorkingDir: options?.workdir, + Env, + Tty: logToPid1 ? false : options?.tty ?? false, + AttachStdin: false, + }); + + try { + const { stdout, stderr, exitCode } = await this.startAndCollectExec( + exec, + options?.timeoutMs, + options?.idleTimeoutMs, + options?.signal, + options?.onOutput, + ); + this.debug( + `Exec finished cid=${inspectData.Id.substring(0, 12)} exitCode=${exitCode} stdoutBytes=${stdout.length} stderrBytes=${stderr.length}`, + ); + return { stdout, stderr, exitCode }; + } catch (err: unknown) { + const isHardTimeout = isExecTimeoutError(err); + const isIdleTimeout = isExecIdleTimeoutError(err); + if (isHardTimeout || isIdleTimeout) { + const reason = isIdleTimeout ? 'idle_timeout' : 'timeout'; + const timeoutValue = (err as ExecTimeoutError | ExecIdleTimeoutError).timeoutMs; + await this.terminateExecProcess(exec, { + containerId, + reason, + timeoutMs: isHardTimeout ? options?.timeoutMs ?? timeoutValue : options?.timeoutMs, + idleTimeoutMs: isIdleTimeout ? options?.idleTimeoutMs ?? timeoutValue : options?.idleTimeoutMs, + }); + } + throw err; + } + } + + /** + * Open a long-lived interactive exec session (duplex) suitable for protocols like MCP over stdio. + * Caller is responsible for closing the returned streams via close(). + */ + async openInteractiveExec( + containerId: string, + command: string[] | string, + options?: InteractiveExecOptions, + ): Promise { + const container = this.docker.getContainer(containerId); + const inspectData = await container.inspect(); + if (inspectData.State?.Running !== true) { + throw new Error(`Container '${containerId}' is not running`); + } + + const Cmd = Array.isArray(command) ? command : ['/bin/sh', '-lc', command]; + const Env: string[] | undefined = Array.isArray(options?.env) + ? options?.env + : options?.env + ? Object.entries(options.env).map(([k, v]) => `${k}=${v}`) + : undefined; + const tty = options?.tty ?? false; // Keep false for clean protocol framing + const demux = options?.demuxStderr ?? true; + + this.debug( + `Interactive exec in container cid=${inspectData.Id.substring(0, 12)} tty=${tty} demux=${demux}: ${Cmd.join(' ')}`, + ); + // Update last-used before starting interactive exec + void this.touchLastUsed(inspectData.Id); + + const exec: Exec = await container.exec({ + Cmd, + AttachStdout: true, + AttachStderr: true, + AttachStdin: true, + WorkingDir: options?.workdir, + Env, + Tty: tty, + }); + + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + const stdoutCollector = createUtf8Collector(INTERACTIVE_EXEC_CLOSE_CAPTURE_LIMIT); + const stderrCollector = createUtf8Collector(INTERACTIVE_EXEC_CLOSE_CAPTURE_LIMIT); + const append = (collector: ReturnType, chunk: Buffer | string) => { + collector.append(chunk); + }; + const flushCollectors = () => { + try { + stdoutCollector.flush(); + } catch { + // ignore flush errors + } + try { + stderrCollector.flush(); + } catch { + // ignore flush errors + } + }; + stdoutStream.on('data', (chunk: Buffer | string) => append(stdoutCollector, chunk)); + stdoutStream.on('end', flushCollectors); + stdoutStream.on('close', flushCollectors); + + // Hijacked stream is duplex (readable+writeable) + const hijackStream: NodeJS.ReadWriteStream = (await new Promise((resolve, reject) => { + exec.start({ hijack: true, stdin: true }, (err, stream) => { + if (err) return reject(err); + if (!stream) return reject(new Error('No stream returned from exec.start')); + resolve(stream as NodeJS.ReadWriteStream); + }); + })) as NodeJS.ReadWriteStream; + + if (!tty && demux) { + // Prefer docker modem demux; fall back to manual demux if unavailable or throws + try { + // Narrow modem type to expected shape for demux + const modemObj = this.docker.modem as unknown; + const modem = modemObj as { + demuxStream?: (s: NodeJS.ReadableStream, out: NodeJS.WritableStream, err: NodeJS.WritableStream) => void; + }; + if (!modem?.demuxStream) throw new Error('demuxStream not available'); + modem.demuxStream(hijackStream, stdoutStream, stderrStream); + } catch { + const { stdout, stderr } = demuxDockerMultiplex(hijackStream); + stdout.pipe(stdoutStream); + stderr.pipe(stderrStream); + } + stderrStream.on('data', (chunk: Buffer | string) => append(stderrCollector, chunk)); + const flushStderr = () => { + try { + stderrCollector.flush(); + } catch { + // ignore flush errors + } + }; + stderrStream.on('end', flushStderr); + stderrStream.on('close', flushStderr); + } else { + hijackStream.pipe(stdoutStream); + } + + const closeOutputs = () => { + try { + stdoutStream.end(); + } catch { + // ignore close errors + } + if (demux) { + try { + stderrStream.end(); + } catch { + // ignore close errors + } + } + }; + + hijackStream.once('end', closeOutputs); + hijackStream.once('close', closeOutputs); + + const execDetails = await exec.inspect(); + const execId = execDetails.ID ?? 'unknown'; + + const terminateProcessGroup = async (reason: 'timeout' | 'idle_timeout'): Promise => { + await this.terminateExecProcess(exec, { containerId: inspectData.Id, reason }); + }; + + const inspect = async (): Promise => exec.inspect(); + + const close = async (): Promise<{ exitCode: number; stdout: string; stderr: string }> => { + try { + hijackStream.end(); + } catch { + // ignore stream end errors + } + // Wait a short grace period; then inspect + const details = await exec.inspect(); + flushCollectors(); + const exitCode = details.ExitCode ?? -1; + const stdout = stdoutCollector.getText(); + const stderrText = demux ? stderrCollector.getText() : ''; + if (stdoutCollector.isTruncated() || stderrCollector.isTruncated()) { + this.warn('Interactive exec close output truncated', { + container: inspectData.Id.substring(0, 12), + execId, + limit: INTERACTIVE_EXEC_CLOSE_CAPTURE_LIMIT, + }); + } + return { exitCode, stdout, stderr: stderrText }; + }; + + return { + stdin: hijackStream, + stdout: stdoutStream, + stderr: demux ? stderrStream : undefined, + close, + execId, + terminateProcessGroup, + inspect, + }; + } + + async streamContainerLogs(containerId: string, options: LogsStreamOptions = {}): Promise { + const container = this.docker.getContainer(containerId); + const inspectData = await container.inspect(); + if (!inspectData) throw new Error(`Container '${containerId}' not found`); + + const followFlag = options.follow !== false; + const stdout = options.stdout ?? true; + const stderr = options.stderr ?? true; + const tail = typeof options.tail === 'number' ? options.tail : undefined; + const since = typeof options.since === 'number' ? options.since : undefined; + const timestamps = options.timestamps ?? false; + + const rawStream = await new Promise((resolve, reject) => { + if (followFlag) { + container.logs( + { + follow: true, + stdout, + stderr, + tail, + since, + timestamps, + }, + (err: Error | null, stream?: NodeJS.ReadableStream) => { + if (err) return reject(err); + if (!stream) return reject(new Error('No log stream returned')); + resolve(stream); + }, + ); + return; + } + + container.logs( + { + follow: false, + stdout, + stderr, + tail, + since, + timestamps, + }, + (err: Error | null, stream?: Buffer) => { + if (err) return reject(err); + if (!stream) return reject(new Error('No log stream returned')); + resolve(stream); + }, + ); + }); + + void this.touchLastUsed(inspectData.Id); + + const stream: NodeJS.ReadableStream = Buffer.isBuffer(rawStream) + ? (() => { + const passthrough = new PassThrough(); + passthrough.end(rawStream); + return passthrough; + })() + : rawStream; + + const close = async (): Promise => { + if (!Buffer.isBuffer(rawStream)) { + const candidate = rawStream as NodeJS.ReadableStream & { destroy?: (error?: Error) => void }; + if (typeof candidate.destroy === 'function') candidate.destroy(); + } + }; + + return { stream, close }; + } + + async resizeExec(execId: string, size: { cols: number; rows: number }): Promise { + const exec = this.docker.getExec(execId); + if (!exec) throw new Error('exec_not_found'); + await exec.resize({ w: size.cols, h: size.rows }); + } + + private buildLogToPid1Command(command: string[] | string): string[] { + const script = 'set -o pipefail; { "$@" ; } 2> >(tee -a /proc/1/fd/2 >&2) | tee -a /proc/1/fd/1'; + const placeholder = '__hautech_exec__'; + if (Array.isArray(command)) { + return ['/bin/bash', '-lc', script, placeholder, ...command]; + } + return ['/bin/bash', '-lc', script, placeholder, '/bin/bash', '-lc', command]; + } + + private startAndCollectExec( + exec: Exec, + timeoutMs?: number, + idleTimeoutMs?: number, + signal?: AbortSignal, + onOutput?: (source: 'stdout' | 'stderr', chunk: Buffer) => void, + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + const destroyIfPossible = (s: unknown) => { + if (s && typeof s === 'object' && 'destroy' in (s as Record)) { + const d = (s as { destroy?: unknown }).destroy; + if (typeof d === 'function') { + try { + (d as () => void).call(s); + } catch { + // ignore destroy errors + } + } + } + }; + const stdoutCollector = createUtf8Collector(); + const stderrCollector = createUtf8Collector(); + let finished = false; + // Underlying hijacked stream reference, to destroy on timeouts + let streamRef: NodeJS.ReadableStream | null = null; + const clearAll = (...ts: (NodeJS.Timeout | null)[]) => ts.forEach((t) => t && clearTimeout(t)); + const onAbort = () => { + if (finished) return; + finished = true; + // Tear down underlying stream on abort if available + destroyIfPossible(streamRef); + try { + stdoutCollector.flush(); + stderrCollector.flush(); + } catch { + // ignore collector flush errors + } + // Properly-typed AbortError without casts + const abortErr = new Error('Aborted'); + abortErr.name = 'AbortError'; + reject(abortErr); + }; + if (signal) { + if (signal.aborted) return onAbort(); + signal.addEventListener('abort', onAbort, { once: true }); + } + const execTimer = + timeoutMs && timeoutMs > 0 + ? setTimeout(() => { + if (finished) return; + finished = true; + // Ensure underlying stream is torn down to avoid further data/timers + destroyIfPossible(streamRef); + try { + stdoutCollector.flush(); + stderrCollector.flush(); + } catch { + // ignore collector flush errors + } + reject(new ExecTimeoutError(timeoutMs!, stdoutCollector.getText(), stderrCollector.getText())); + }, timeoutMs) + : null; + let idleTimer: NodeJS.Timeout | null = null; + const armIdle = () => { + if (finished) return; // do not arm after completion + if (!idleTimeoutMs || idleTimeoutMs <= 0) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (finished) return; + finished = true; + // Ensure underlying stream is torn down to avoid further data/timers + destroyIfPossible(streamRef); + try { + stdoutCollector.flush(); + stderrCollector.flush(); + } catch { + // ignore collector flush errors + } + reject(new ExecIdleTimeoutError(idleTimeoutMs!, stdoutCollector.getText(), stderrCollector.getText())); + }, idleTimeoutMs); + }; + + exec.start({ hijack: true, stdin: false }, (err, stream) => { + if (err) { + clearAll(execTimer, idleTimer); + if (signal) + try { + signal.removeEventListener('abort', onAbort); + } catch { + // ignore listener removal errors + } + return reject(err); + } + if (!stream) { + clearAll(execTimer, idleTimer); + if (signal) + try { + signal.removeEventListener('abort', onAbort); + } catch { + // ignore listener removal errors + } + return reject(new Error('No stream returned from exec.start')); + } + + // If exec created without TTY, docker multiplexes stdout/stderr + // capture stream for timeout teardown + streamRef = stream; + if (!exec.inspect) { + // Very unlikely, but guard. + this.error('Exec instance missing inspect method'); + } + + // Try to determine if we should demux. We'll inspect later. + (async () => { + try { + const details = await exec.inspect(); + const tty = details.ProcessConfig?.tty; + armIdle(); + if (tty) { + stream.on('data', (chunk: Buffer | string) => { + if (finished) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + if (onOutput) { + try { + onOutput('stdout', buf); + } catch (cbErr) { + this.warn('exec onOutput callback failed', { + source: 'stdout', + error: this.errorContext(cbErr), + }); + } + } + stdoutCollector.append(buf); + armIdle(); + }); + } else { + // Prefer docker.modem.demuxStream; fall back to manual demux if needed + const outStdout = new Writable({ + write: (chunk, _enc, cb) => { + if (!finished) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + if (onOutput) { + try { + onOutput('stdout', buf); + } catch (cbErr) { + this.warn('exec onOutput callback failed', { + source: 'stdout', + error: this.errorContext(cbErr), + }); + } + } + stdoutCollector.append(buf); + armIdle(); + } + cb(); + }, + }); + const outStderr = new Writable({ + write: (chunk, _enc, cb) => { + if (!finished) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + if (onOutput) { + try { + onOutput('stderr', buf); + } catch (cbErr) { + this.warn('exec onOutput callback failed', { + source: 'stderr', + error: this.errorContext(cbErr), + }); + } + } + stderrCollector.append(buf); + armIdle(); + } + cb(); + }, + }); + try { + this.demuxTo(stream, outStdout, outStderr); + } catch { + const { stdout, stderr } = demuxDockerMultiplex(stream); + stdout.pipe(outStdout); + stderr.pipe(outStderr); + } + } + } catch { + // Fallback: treat as single combined stream + armIdle(); + stream.on('data', (c: Buffer | string) => { + if (finished) return; + const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c)); + if (onOutput) { + try { + onOutput('stdout', buf); + } catch (cbErr) { + this.logger.warn('exec onOutput callback failed', { source: 'stdout', error: cbErr }); + } + } + stdoutCollector.append(buf); + armIdle(); + }); + } + })(); + + stream.on('end', async () => { + if (finished) return; // already timed out + try { + const inspectData = await exec.inspect(); + clearAll(execTimer, idleTimer); + if (signal) + try { + signal.removeEventListener('abort', onAbort); + } catch { + // ignore listener removal errors + } + finished = true; + try { + stdoutCollector.flush(); + stderrCollector.flush(); + } catch { + // ignore collector flush errors + } + resolve({ + stdout: stdoutCollector.getText(), + stderr: stderrCollector.getText(), + exitCode: inspectData.ExitCode ?? -1, + }); + } catch (e) { + clearAll(execTimer, idleTimer); + if (signal) + try { + signal.removeEventListener('abort', onAbort); + } catch { + // ignore listener removal errors + } + finished = true; + reject(e); + } + }); + stream.on('error', (e) => { + if (finished) return; + clearAll(execTimer, idleTimer); + if (signal) + try { + signal.removeEventListener('abort', onAbort); + } catch { + // ignore listener removal errors + } + // Flush decoders to avoid dropping partial code units + try { + stdoutCollector.flush(); + stderrCollector.flush(); + } catch { + // ignore collector flush errors + } + finished = true; + reject(e); + }); + // Extra safety: clear timers on close as well + stream.on('close', () => { + clearAll(execTimer, idleTimer); + if (signal) + try { + signal.removeEventListener('abort', onAbort); + } catch { + // ignore listener removal errors + } + }); + }); + }); + } + + private async terminateExecProcess( + exec: Exec, + context: { containerId: string; reason: 'timeout' | 'idle_timeout'; timeoutMs?: number; idleTimeoutMs?: number }, + ): Promise { + try { + const details = await exec.inspect(); + const execId = typeof details.ID === 'string' ? details.ID : undefined; + const pid = typeof details.Pid === 'number' ? details.Pid : undefined; + if (!pid || pid <= 0) { + this.warn('Exec timeout detected but PID unavailable; skipping process termination', { + containerId: context.containerId, + execId, + reason: context.reason, + }); + return; + } + + const baseLog: Record = { + containerId: context.containerId, + execId, + pid, + reason: context.reason, + timeoutMs: context.timeoutMs, + idleTimeoutMs: context.idleTimeoutMs, + }; + + this.warn('Exec timeout detected; terminating process group', baseLog); + this.signalExecProcess(pid, 'SIGTERM', baseLog); + await this.delay(750); + if (!this.isProcessAlive(pid)) return; + this.warn('Exec process group still running after SIGTERM; sending SIGKILL', baseLog); + this.signalExecProcess(pid, 'SIGKILL', baseLog); + await this.delay(150); + } catch (error) { + this.warn('Failed to terminate exec process group after timeout', { + containerId: context.containerId, + reason: context.reason, + error: this.errorContext(error), + }); + } + } + + private signalExecProcess(pid: number, signal: NodeJS.Signals, context: Record): void { + const targets = [-Math.abs(pid), pid]; + for (const target of targets) { + try { + process.kill(target, signal); + this.debug('Sent signal to exec process target', { ...context, signal, target }); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ESRCH') continue; + this.warn('Failed to signal exec process target', { + ...context, + signal, + target, + error: this.errorContext(err), + }); + } + } + } + + private async delay(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException; + return err?.code !== 'ESRCH'; + } + } + + // Demultiplex docker multiplexed stream if modem.demuxStream is available; otherwise fall back to manual demux + private demuxTo( + stream: NodeJS.ReadableStream, + outStdout: NodeJS.WritableStream, + outStderr: NodeJS.WritableStream, + ): void { + const modemObj = this.docker.modem; + const demux = modemObj?.['demuxStream'] as + | ((s: NodeJS.ReadableStream, out: NodeJS.WritableStream, err: NodeJS.WritableStream) => void) + | undefined; + if (demux) { + demux(stream, outStdout, outStderr); + return; + } + const { stdout, stderr } = demuxDockerMultiplex(stream); + stdout.pipe(outStdout); + stderr.pipe(outStderr); + } + + /** Stop a container by docker id (gracefully). */ + async stopContainer(containerId: string, timeoutSec = 10): Promise { + this.log(`Stopping container cid=${containerId.substring(0, 12)} (timeout=${timeoutSec}s)`); + const c = this.docker.getContainer(containerId); + try { + await c.stop({ t: timeoutSec }); + } catch (e: unknown) { + const sc = typeof e === 'object' && e && 'statusCode' in e ? (e as { statusCode?: number }).statusCode : undefined; + if (sc === 304) { + this.debug(`Container already stopped cid=${containerId.substring(0, 12)}`); + } else if (sc === 404) { + this.debug(`Container missing during stop cid=${containerId.substring(0, 12)}`); + } else if (sc === 409) { + // Conflict typically indicates removal already in progress; treat as benign + this.warn(`Container stop conflict (likely removing) cid=${containerId.substring(0, 12)}`); + } else { + throw e; + } + } + } + + /** Remove a container by docker id. */ + async removeContainer( + containerId: string, + options?: boolean | { force?: boolean; removeVolumes?: boolean }, + ): Promise { + const opts = typeof options === 'boolean' ? { force: options } : options; + const force = opts?.force ?? false; + const removeVolumes = opts?.removeVolumes ?? false; + this.log( + `Removing container cid=${containerId.substring(0, 12)} force=${force} removeVolumes=${removeVolumes}`, + ); + const container = this.docker.getContainer(containerId); + try { + await container.remove({ force, v: removeVolumes }); + } catch (e: unknown) { + const sc = typeof e === 'object' && e && 'statusCode' in e ? (e as { statusCode?: number }).statusCode : undefined; + if (sc === 404) { + this.debug(`Container already removed cid=${containerId.substring(0, 12)}`); + } else if (sc === 409) { + this.warn(`Container removal conflict cid=${containerId.substring(0, 12)} (likely removing)`); + } else { + throw e; + } + } + } + + /** Inspect and return container labels */ + async getContainerLabels(containerId: string): Promise | undefined> { + const container = this.docker.getContainer(containerId); + const details = await container.inspect(); + return details.Config?.Labels ?? undefined; + } + + async inspectContainer(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + return container.inspect(); + } + + async getEventsStream(options: { since?: number; filters?: GetEventsOptions['filters'] }): Promise { + return new Promise((resolve, reject) => { + this.docker.getEvents({ since: options.since, filters: options.filters }, (err?: Error, stream?: NodeJS.ReadableStream) => { + if (err) return reject(err); + if (!stream) return reject(new Error('events_stream_unavailable')); + resolve(stream); + }); + }); + } + + /** Inspect and return the list of docker networks the container is attached to */ + async getContainerNetworks(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + const details = await container.inspect(); + const networks = details.NetworkSettings?.Networks ?? {}; + return Object.keys(networks); + } + + /** + * Find running (default) or all containers that match ALL provided labels. + * Returns an array of ContainerEntity instances (may be empty). + * + * @param labels Key/value label pairs to match (logical AND) + * @param options.all If true, include stopped containers as well + */ + async findContainersByLabels( + labels: Record, + options?: { all?: boolean }, + ): Promise { + const labelFilters = Object.entries(labels).map(([k, v]) => `${k}=${v}`); + this.log(`Listing containers by labels all=${options?.all ?? false} filters=${labelFilters.join(',')}`); + // dockerode returns Docker.ContainerInfo[]; type explicitly for comparator safety + const list: Docker.ContainerInfo[] = await this.docker.listContainers({ + all: options?.all ?? false, + filters: { label: labelFilters }, + }); + // Deterministic ordering to stabilize selection; sort by Created then Id + // Note: explicit Docker.ContainerInfo types avoid any in comparator. + const sorted = [...list].sort((a: Docker.ContainerInfo, b: Docker.ContainerInfo) => { + const ac = typeof a.Created === 'number' ? a.Created : 0; + const bc = typeof b.Created === 'number' ? b.Created : 0; + if (ac !== bc) return ac - bc; // ascending by Created + const aid = String(a.Id ?? ''); + const bid = String(b.Id ?? ''); + return aid.localeCompare(bid); + }); + return sorted.map((c) => new ContainerHandle(this, c.Id)); + } + + async listContainersByVolume(volumeName: string): Promise { + if (!volumeName) return []; + const result = await this.docker.listContainers({ all: true, filters: { volume: [volumeName] } }); + return Array.isArray(result) ? result.map((it) => it.Id) : []; + } + + async removeVolume(volumeName: string, options?: { force?: boolean }): Promise { + const force = options?.force ?? false; + this.log(`Removing volume name=${volumeName} force=${force}`); + const volume = this.docker.getVolume(volumeName); + try { + await volume.remove({ force }); + } catch (e: unknown) { + const sc = typeof e === 'object' && e && 'statusCode' in e ? (e as { statusCode?: number }).statusCode : undefined; + if (sc === 404) { + this.debug(`Volume already removed name=${volumeName}`); + } else { + throw e; + } + } + } + + /** + * Convenience wrapper returning the first container that matches all labels (or undefined). + */ + async findContainerByLabels( + labels: Record, + options?: { all?: boolean }, + ): Promise { + const containers = await this.findContainersByLabels(labels, options); + return containers[0]; + } + + getDocker(): Docker { + return this.docker; + } + + /** + * Upload a tar archive into the container filesystem at the specified path. + * Intended for saving large tool outputs into /tmp of the workspace container. + */ + async putArchive( + containerId: string, + data: Buffer | NodeJS.ReadableStream, + options: { path: string }, + ): Promise { + const container = this.docker.getContainer(containerId); + const inspectData = await container.inspect(); + if (inspectData.State?.Running !== true) { + throw new Error(`Container '${containerId}' is not running`); + } + this.debug( + `putArchive into container cid=${inspectData.Id.substring(0, 12)} path=${options?.path || ''} bytes=${Buffer.isBuffer(data) ? data.length : 'stream'}`, + ); + if (Buffer.isBuffer(data)) await container.putArchive(data, options); + else await container.putArchive(data, options); + void this.touchLastUsed(inspectData.Id); + } +} diff --git a/packages/docker-runner/src/service/grpc/server.ts b/packages/docker-runner/src/service/grpc/server.ts new file mode 100644 index 000000000..837b88fcf --- /dev/null +++ b/packages/docker-runner/src/service/grpc/server.ts @@ -0,0 +1,1226 @@ +import { Code, ConnectError, type ConnectRouter, type HandlerContext } from '@connectrpc/connect'; +import { connectNodeAdapter } from '@connectrpc/connect-node'; +import { createServer as createHttp2Server, type Http2Server } from 'node:http2'; +import { + CancelExecutionResponseSchema, + ExecErrorSchema, + ExecExitReason, + ExecExitSchema, + ExecOutputSchema, + ExecRequest, + ExecResponse, + ExecStartRequest, + ExecResponseSchema, + ExecStartedSchema, + FindWorkloadsByLabelsResponseSchema, + GetWorkloadLabelsResponseSchema, + InspectWorkloadResponseSchema, + ListWorkloadsByVolumeResponseSchema, + LogChunkSchema, + LogEndSchema, + PutArchiveResponseSchema, + ReadyResponseSchema, + RemoveVolumeResponseSchema, + RemoveWorkloadResponseSchema, + RunnerError, + RunnerErrorSchema, + RunnerEventDataSchema, + StartWorkloadResponseSchema, + StopWorkloadResponseSchema, + StreamEventsRequest, + StreamEventsResponse, + StreamEventsResponseSchema, + StreamWorkloadLogsResponse, + StreamWorkloadLogsResponseSchema, + TargetMountSchema, + TouchWorkloadResponseSchema, + SidecarInstance, + SidecarInstanceSchema, + WorkloadContainersSchema, + WorkloadStatus, + RunnerService, +} from '../../proto/gen/agynio/api/runner/v1/runner_pb.js'; +import { timestampFromDate } from '@bufbuild/protobuf/wkt'; +import { create } from '@bufbuild/protobuf'; +import type { ContainerService, InteractiveExecSession, LogsStreamSession, NonceCache } from '../..'; +import type { ContainerHandle } from '../../lib/container.handle'; +import { verifyAuthHeaders } from '../..'; +import type { RunnerConfig } from '../config'; +import { createDockerEventsParser } from '../dockerEvents.parser'; +import { startWorkloadRequestToContainerOpts } from '../../contracts/workload.grpc'; + +export type RunnerGrpcOptions = { + config: RunnerConfig; + containers: ContainerService; + nonceCache: NonceCache; +}; + +type ExecutionContext = { + executionId: string; + targetId: string; + requestId: string; + session: InteractiveExecSession; + startedAt: Date; + stdoutSeq: bigint; + stderrSeq: bigint; + exitTailBytes: number; + killOnTimeout: boolean; + timeoutMs?: number; + idleTimeoutMs?: number; + finished: boolean; + cancelRequested: boolean; + timers: { + timeout?: NodeJS.Timeout; + idle?: NodeJS.Timeout; + completion?: NodeJS.Timeout; + }; + reason: ExecExitReason; + killed: boolean; + finish?: (reason: ExecExitReason, killed?: boolean) => Promise; +}; + +const activeExecutions = new Map(); + +const runnerServicePath = (method: keyof typeof RunnerService.method): string => + `/${RunnerService.typeName}/${RunnerService.method[method].name}`; + +const createAsyncQueue = () => { + const queue: T[] = []; + let resolve: (() => void) | undefined; + let ended = false; + + const notify = () => { + if (resolve) { + resolve(); + resolve = undefined; + } + }; + + const push = (value: T) => { + if (ended) return; + queue.push(value); + notify(); + }; + + const end = () => { + if (ended) return; + ended = true; + notify(); + }; + + const iterate = async function* () { + while (true) { + if (queue.length > 0) { + yield queue.shift() as T; + continue; + } + if (ended) return; + await new Promise((resolveWait) => { + resolve = resolveWait; + }); + } + }; + + return { push, end, iterate }; +}; + +const clearExecutionTimers = (ctx?: ExecutionContext) => { + if (!ctx) return; + if (ctx.timers.timeout) { + clearTimeout(ctx.timers.timeout); + ctx.timers.timeout = undefined; + } + if (ctx.timers.idle) { + clearTimeout(ctx.timers.idle); + ctx.timers.idle = undefined; + } + if (ctx.timers.completion) { + clearTimeout(ctx.timers.completion); + ctx.timers.completion = undefined; + } +}; + +const utf8Encoder = new TextEncoder(); +const DEFAULT_EXIT_TAIL_BYTES = 64 * 1024; +const MAX_EXIT_TAIL_BYTES = 256 * 1024; +const CONTAINER_STOP_TIMEOUT_SEC = 10; +const SIDECAR_ROLE_LABEL = 'hautech.ai/role'; +const SIDECAR_ROLE_VALUE = 'sidecar'; +const PARENT_CONTAINER_LABEL = 'hautech.ai/parent_cid'; + +async function findSidecarHandles(containers: ContainerService, workloadId: string): Promise { + try { + return await containers.findContainersByLabels( + { + [SIDECAR_ROLE_LABEL]: SIDECAR_ROLE_VALUE, + [PARENT_CONTAINER_LABEL]: workloadId, + }, + { all: true }, + ); + } catch { + return []; + } +} + +async function stopSidecars(containers: ContainerService, workloadId: string, timeoutSec: number): Promise { + const handles = await findSidecarHandles(containers, workloadId); + for (const handle of handles) { + try { + await containers.stopContainer(handle.id, timeoutSec); + } catch { + // ignore sidecar stop failures + } + } +} + +async function removeSidecars( + containers: ContainerService, + workloadId: string, + options: { force?: boolean; removeVolumes?: boolean }, +): Promise { + const handles = await findSidecarHandles(containers, workloadId); + for (const handle of handles) { + try { + await containers.removeContainer(handle.id, options); + } catch { + // ignore sidecar removal failures + } + } +} + +type DockerErrorDetails = { + statusCode?: number; + status?: number; + reason?: string; + statusMessage?: string; + code?: string; + message?: string; + json?: { message?: string }; +}; + +type ExtractedDockerError = { + statusCode: number; + code?: string; + message?: string; +}; + +const normalizeCode = (value?: string): string | undefined => { + if (!value) return undefined; + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return undefined; + const normalized = trimmed.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + return normalized || undefined; +}; + +const extractDockerError = (error: unknown): ExtractedDockerError | null => { + if (!error || typeof error !== 'object') return null; + const candidate = error as DockerErrorDetails; + const statusCode = candidate.statusCode ?? candidate.status; + if (typeof statusCode !== 'number') return null; + const message = candidate.json?.message?.trim() ?? candidate.message?.trim() ?? candidate.reason?.trim() ?? candidate.statusMessage?.trim(); + const code = candidate.code ?? normalizeCode(candidate.reason ?? candidate.statusMessage); + return { statusCode, code: code ?? undefined, message: message ?? undefined }; +}; + +const mapStatusCodeToCode = (statusCode: number | undefined, fallback: Code): Code => { + if (typeof statusCode !== 'number' || statusCode <= 0) return fallback; + switch (statusCode) { + case 400: + case 422: + return Code.InvalidArgument; + case 401: + return Code.Unauthenticated; + case 403: + return Code.PermissionDenied; + case 404: + return Code.NotFound; + case 409: + return Code.Aborted; + case 412: + return Code.FailedPrecondition; + case 429: + return Code.ResourceExhausted; + case 499: + return Code.Canceled; + case 500: + return Code.Internal; + case 502: + case 503: + case 504: + return Code.Unavailable; + default: + if (statusCode >= 500) return Code.Unavailable; + return fallback; + } +}; + +const errorMessageFromUnknown = (error: unknown, fallback: string): string => { + if (error instanceof Error && error.message) return error.message; + if (typeof error === 'string' && error.trim()) return error.trim(); + return fallback; +}; + +const toDockerServiceError = ( + error: unknown, + fallbackStatus: Code, + fallbackMessage = 'runner_error', +): ConnectError => { + const extracted = extractDockerError(error); + const message = extracted?.message ?? errorMessageFromUnknown(error, fallbackMessage); + const code = mapStatusCodeToCode(extracted?.statusCode, fallbackStatus); + return new ConnectError(message, code); +}; + +const toRunnerStreamError = ( + error: unknown, + defaultCode: string, + fallbackMessage: string, + fallbackRetryable = false, +): RunnerError => { + const extracted = extractDockerError(error); + const message = extracted?.message ?? errorMessageFromUnknown(error, fallbackMessage); + const retryable = extracted ? extracted.statusCode >= 500 : fallbackRetryable; + const code = extracted?.code ?? defaultCode; + return create(RunnerErrorSchema, { + code, + message, + details: {}, + retryable, + }); +}; + +const bigintToNumber = (value?: bigint): number | undefined => { + if (typeof value !== 'bigint') return undefined; + const num = Number(value); + return Number.isFinite(num) ? num : undefined; +}; + +const buildEventFilters = (filters: StreamEventsRequest['filters']): Record => { + const result: Record = {}; + for (const filter of filters ?? []) { + const key = filter?.key?.trim(); + if (!key) continue; + const values = (filter.values ?? []) + .map((value: string | undefined) => (typeof value === 'string' ? value.trim() : '')) + .filter((value: string): value is string => value.length > 0); + if (!values.length) continue; + result[key] = result[key] ? [...result[key], ...values] : values; + } + return result; +}; + +const safeQueuePush = (queue: { push: (message: T) => void }, message: T): void => { + try { + queue.push(message); + } catch { + // ignore downstream cancellation errors + } +}; + +function coerceDuration(value?: bigint): number | undefined { + if (typeof value !== 'bigint') return undefined; + if (value <= 0n) return undefined; + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return undefined; + return num; +} + +function headersToRecord(headers: Headers): Record { + const result: Record = {}; + for (const [key, value] of headers.entries()) { + result[key] = value; + } + return result; +} + +function createRunnerError(code: string, message: string, retryable: boolean) { + return create(ExecErrorSchema, { code, message, retryable }); +} + +function toServiceError(code: Code, message: string): ConnectError { + return new ConnectError(message, code); +} + +function verifyConnectAuth({ + header, + secret, + nonceCache, + path, +}: { + header: Headers; + secret: string; + nonceCache: NonceCache; + path: string; +}) { + return verifyAuthHeaders({ + headers: headersToRecord(header), + method: 'POST', + path, + body: '', + secret, + nonceCache, + }); +} + +function utf8Tail(data: string, maxBytes: number): Uint8Array { + if (maxBytes <= 0) return new Uint8Array(); + const encoded = utf8Encoder.encode(data); + if (encoded.byteLength <= maxBytes) return encoded; + return encoded.subarray(encoded.byteLength - maxBytes); +} + +export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { + const requireAuth = (context: HandlerContext, method: keyof typeof RunnerService.method) => { + const verification = verifyConnectAuth({ + header: context.requestHeader, + secret: opts.config.sharedSecret, + nonceCache: opts.nonceCache, + path: runnerServicePath(method), + }); + if (!verification.ok) { + throw toServiceError(Code.Unauthenticated, verification.message ?? 'unauthorized'); + } + }; + + const routes = (router: ConnectRouter) => + router.service(RunnerService, { + ready: async (_request, context) => { + requireAuth(context, 'ready'); + try { + await opts.containers.getDocker().ping(); + } catch (error) { + throw toServiceError(Code.Unavailable, error instanceof Error ? error.message : String(error)); + } + return create(ReadyResponseSchema, { status: 'ready' }); + }, + startWorkload: async (request, context) => { + requireAuth(context, 'startWorkload'); + if (!request?.main) { + throw toServiceError(Code.InvalidArgument, 'main_container_required'); + } + try { + const containerOpts = startWorkloadRequestToContainerOpts(request); + const sidecarOpts = Array.isArray(containerOpts.sidecars) ? containerOpts.sidecars : []; + const stopAndRemove = async (containerId: string) => { + try { + await opts.containers.stopContainer(containerId, CONTAINER_STOP_TIMEOUT_SEC); + } catch { + // ignore stop errors during rollback + } + try { + await opts.containers.removeContainer(containerId, { force: true, removeVolumes: true }); + } catch { + // ignore removal errors during rollback + } + }; + + const mainHandle = await opts.containers.start(containerOpts); + + const startedSidecars: ContainerHandle[] = []; + const sidecarInstances: SidecarInstance[] = []; + + const describeSidecar = async ( + containerId: string, + fallbackName: string, + ): Promise<{ name: string; status: string }> => { + try { + const inspect = await opts.containers.inspectContainer(containerId); + const rawName = typeof inspect.Name === 'string' ? inspect.Name.replace(/^\/+/, '') : ''; + const name = rawName || fallbackName; + const statusLabel = inspect.State?.Status ? String(inspect.State.Status) : 'running'; + return { name, status: statusLabel }; + } catch { + return { name: fallbackName, status: 'running' }; + } + }; + + try { + for (let index = 0; index < sidecarOpts.length; index += 1) { + const sidecar = sidecarOpts[index]; + const labels = { + ...(sidecar.labels ?? {}), + [SIDECAR_ROLE_LABEL]: SIDECAR_ROLE_VALUE, + [PARENT_CONTAINER_LABEL]: mainHandle.id, + }; + const networkMode = + sidecar.networkMode === 'container:main' + ? `container:${mainHandle.id}` + : sidecar.networkMode; + + const sidecarHandle = await opts.containers.start({ + image: sidecar.image, + cmd: sidecar.cmd, + env: sidecar.env, + autoRemove: sidecar.autoRemove, + anonymousVolumes: sidecar.anonymousVolumes, + privileged: sidecar.privileged, + createExtras: sidecar.createExtras, + networkMode, + labels, + }); + startedSidecars.push(sidecarHandle); + + const fallbackName = `sidecar-${index + 1}`; + const { name: reportedName, status: reportedStatus } = await describeSidecar( + sidecarHandle.id, + fallbackName, + ); + + sidecarInstances.push( + create(SidecarInstanceSchema, { + name: reportedName, + id: sidecarHandle.id, + status: reportedStatus, + }), + ); + } + } catch (error) { + for (const sidecarHandle of startedSidecars.reverse()) { + await stopAndRemove(sidecarHandle.id); + } + await stopAndRemove(mainHandle.id); + throw error; + } + + return create(StartWorkloadResponseSchema, { + id: mainHandle.id, + containers: create(WorkloadContainersSchema, { main: mainHandle.id, sidecars: sidecarInstances }), + status: WorkloadStatus.RUNNING, + }); + } catch (error) { + if (error instanceof Error && error.message === 'main_container_spec_required') { + throw toServiceError(Code.InvalidArgument, error.message); + } + throw toDockerServiceError(error, Code.Unknown); + } + }, + stopWorkload: async (request, context) => { + requireAuth(context, 'stopWorkload'); + const workloadId = request.workloadId?.trim(); + if (!workloadId) { + throw toServiceError(Code.InvalidArgument, 'workload_id_required'); + } + const timeoutSec = + typeof request.timeoutSec === 'number' && request.timeoutSec >= 0 + ? request.timeoutSec + : CONTAINER_STOP_TIMEOUT_SEC; + try { + await stopSidecars(opts.containers, workloadId, timeoutSec); + await opts.containers.stopContainer(workloadId, timeoutSec); + return create(StopWorkloadResponseSchema, {}); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + removeWorkload: async (request, context) => { + requireAuth(context, 'removeWorkload'); + const workloadId = request.workloadId?.trim(); + if (!workloadId) { + throw toServiceError(Code.InvalidArgument, 'workload_id_required'); + } + try { + await removeSidecars(opts.containers, workloadId, { + force: request.force ?? false, + removeVolumes: request.removeVolumes ?? false, + }); + await opts.containers.removeContainer(workloadId, { + force: request.force ?? false, + removeVolumes: request.removeVolumes ?? false, + }); + return create(RemoveWorkloadResponseSchema, {}); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + inspectWorkload: async (request, context) => { + requireAuth(context, 'inspectWorkload'); + const workloadId = request.workloadId?.trim(); + if (!workloadId) { + throw toServiceError(Code.InvalidArgument, 'workload_id_required'); + } + try { + const details = await opts.containers.inspectContainer(workloadId); + const mounts = (details.Mounts ?? []).map((mount: { + Type?: string | null; + Source?: string | null; + Destination?: string | null; + ReadOnly?: boolean; + RW?: boolean; + }) => + create(TargetMountSchema, { + type: mount.Type ?? '', + source: mount.Source ?? '', + destination: mount.Destination ?? '', + readOnly: mount.ReadOnly === true || mount.RW === false, + }), + ); + return create(InspectWorkloadResponseSchema, { + id: details.Id ?? '', + name: details.Name ?? '', + image: details.Image ?? '', + configImage: details.Config?.Image ?? '', + configLabels: details.Config?.Labels ?? {}, + mounts, + stateStatus: details.State?.Status ?? '', + stateRunning: details.State?.Running === true, + }); + } catch (error) { + throw toDockerServiceError(error, Code.NotFound); + } + }, + getWorkloadLabels: async (request, context) => { + requireAuth(context, 'getWorkloadLabels'); + const workloadId = request.workloadId?.trim(); + if (!workloadId) { + throw toServiceError(Code.InvalidArgument, 'workload_id_required'); + } + try { + const labels = await opts.containers.getContainerLabels(workloadId); + return create(GetWorkloadLabelsResponseSchema, { labels: labels ?? {} }); + } catch (error) { + throw toDockerServiceError(error, Code.NotFound); + } + }, + findWorkloadsByLabels: async (request, context) => { + requireAuth(context, 'findWorkloadsByLabels'); + const labels = request.labels ?? {}; + if (!labels || Object.keys(labels).length === 0) { + throw toServiceError(Code.InvalidArgument, 'labels_required'); + } + try { + const containers = await opts.containers.findContainersByLabels(labels, { all: request.all ?? false }); + return create(FindWorkloadsByLabelsResponseSchema, { + targetIds: containers.map((handle: ContainerHandle) => handle.id), + }); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + listWorkloadsByVolume: async (request, context) => { + requireAuth(context, 'listWorkloadsByVolume'); + const volumeName = request.volumeName?.trim(); + if (!volumeName) { + throw toServiceError(Code.InvalidArgument, 'volume_name_required'); + } + try { + const ids = await opts.containers.listContainersByVolume(volumeName); + return create(ListWorkloadsByVolumeResponseSchema, { targetIds: ids }); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + removeVolume: async (request, context) => { + requireAuth(context, 'removeVolume'); + const volumeName = request.volumeName?.trim(); + if (!volumeName) { + throw toServiceError(Code.InvalidArgument, 'volume_name_required'); + } + try { + await opts.containers.removeVolume(volumeName, { force: request.force ?? false }); + return create(RemoveVolumeResponseSchema, {}); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + touchWorkload: async (request, context) => { + requireAuth(context, 'touchWorkload'); + const workloadId = request.workloadId?.trim(); + if (!workloadId) { + throw toServiceError(Code.InvalidArgument, 'workload_id_required'); + } + try { + await opts.containers.touchLastUsed(workloadId); + return create(TouchWorkloadResponseSchema, {}); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + putArchive: async (request, context) => { + requireAuth(context, 'putArchive'); + const workloadId = request.workloadId?.trim(); + const targetPath = request.path?.trim(); + if (!workloadId || !targetPath) { + throw toServiceError(Code.InvalidArgument, 'workload_id_and_path_required'); + } + try { + await opts.containers.putArchive(workloadId, Buffer.from(request.tarPayload ?? new Uint8Array()), { + path: targetPath, + }); + return create(PutArchiveResponseSchema, {}); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + }, + streamWorkloadLogs: async function* (request, context) { + requireAuth(context, 'streamWorkloadLogs'); + const workloadId = request.workloadId?.trim(); + if (!workloadId) { + throw toServiceError(Code.InvalidArgument, 'workload_id_required'); + } + + const follow = request.follow !== false; + const since = bigintToNumber(request.since); + const tail = typeof request.tail === 'number' && request.tail > 0 ? request.tail : undefined; + const stdout = request.stdout; + const stderr = request.stderr; + const timestamps = request.timestamps; + + let session: LogsStreamSession; + try { + session = await opts.containers.streamContainerLogs(workloadId, { + follow, + since, + tail, + stdout, + stderr, + timestamps, + }); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + + const { stream } = session; + const queue = createAsyncQueue(); + let closed = false; + + const normalizeChunk = (chunk: unknown): Buffer => { + if (Buffer.isBuffer(chunk)) return chunk; + if (chunk instanceof Uint8Array) return Buffer.from(chunk); + if (typeof chunk === 'string') return Buffer.from(chunk); + return Buffer.from([]); + }; + + const cleanup = async () => { + if (closed) return; + closed = true; + stream.removeListener('data', onData); + stream.removeListener('error', onError); + stream.removeListener('end', onEnd); + context.signal.removeEventListener('abort', onAbort); + try { + await session.close(); + } catch { + // ignore cleanup errors + } + queue.end(); + }; + + const onData = (chunk: unknown) => { + const buffer = normalizeChunk(chunk); + safeQueuePush( + queue, + create(StreamWorkloadLogsResponseSchema, { + event: { case: 'chunk', value: create(LogChunkSchema, { data: buffer }) }, + }), + ); + }; + + const onError = (error: unknown) => { + safeQueuePush( + queue, + create(StreamWorkloadLogsResponseSchema, { + event: { case: 'error', value: toRunnerStreamError(error, 'logs_stream_error', 'log stream failed') }, + }), + ); + void cleanup(); + }; + + const onEnd = () => { + safeQueuePush( + queue, + create(StreamWorkloadLogsResponseSchema, { + event: { case: 'end', value: create(LogEndSchema, {}) }, + }), + ); + void cleanup(); + }; + + const onAbort = () => { + void cleanup(); + }; + + stream.on('data', onData); + stream.on('error', onError); + stream.on('end', onEnd); + context.signal.addEventListener('abort', onAbort, { once: true }); + + try { + for await (const message of queue.iterate()) { + yield message; + } + } finally { + await cleanup(); + } + }, + streamEvents: async function* (request, context) { + requireAuth(context, 'streamEvents'); + + const since = bigintToNumber(request.since); + const filters = buildEventFilters(request.filters ?? []); + + let eventsStream: NodeJS.ReadableStream; + try { + eventsStream = await opts.containers.getEventsStream({ + since, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + } catch (error) { + throw toDockerServiceError(error, Code.Unknown); + } + + const queue = createAsyncQueue(); + let closed = false; + + const parser = createDockerEventsParser( + (event: Record) => { + safeQueuePush( + queue, + create(StreamEventsResponseSchema, { + event: { + case: 'data', + value: create(RunnerEventDataSchema, { json: JSON.stringify(event) }), + }, + }), + ); + }, + { + onError: (payload: string, error: unknown) => { + safeQueuePush( + queue, + create(StreamEventsResponseSchema, { + event: { + case: 'error', + value: toRunnerStreamError( + error ?? new Error('events_parse_error'), + 'events_parse_error', + `failed to parse docker event: ${payload}`, + ), + }, + }), + ); + }, + }, + ); + + const cleanup = () => { + if (closed) return; + closed = true; + eventsStream.removeListener('data', onData); + eventsStream.removeListener('error', onError); + eventsStream.removeListener('end', onEnd); + context.signal.removeEventListener('abort', onAbort); + const destroy = (eventsStream as NodeJS.ReadableStream & { destroy?: (error?: Error) => void }).destroy; + if (typeof destroy === 'function') { + destroy.call(eventsStream); + } + queue.end(); + }; + + const onData = (chunk: unknown) => { + parser.handleChunk(chunk as Buffer); + }; + + const onError = (error: unknown) => { + safeQueuePush( + queue, + create(StreamEventsResponseSchema, { + event: { case: 'error', value: toRunnerStreamError(error, 'events_stream_error', 'event stream failed') }, + }), + ); + cleanup(); + }; + + const onEnd = () => { + parser.flush(); + cleanup(); + }; + + const onAbort = () => { + cleanup(); + }; + + eventsStream.on('data', onData); + eventsStream.on('error', onError); + eventsStream.on('end', onEnd); + context.signal.addEventListener('abort', onAbort, { once: true }); + + try { + for await (const message of queue.iterate()) { + yield message; + } + } finally { + cleanup(); + } + }, + exec: async function* (requests, context) { + requireAuth(context, 'exec'); + + const responseQueue = createAsyncQueue(); + let ctx: ExecutionContext | undefined; + let closed = false; + + const closeResponses = () => { + if (closed) return; + closed = true; + responseQueue.end(); + }; + + const writeResponse = (response: ExecResponse) => { + safeQueuePush(responseQueue, response); + }; + + const finish = async (target: ExecutionContext, reason: ExecExitReason, killed = false) => { + if (!target || target.finished) return; + target.finished = true; + target.reason = reason; + target.killed = killed; + clearExecutionTimers(target); + activeExecutions.delete(target.executionId); + try { + const result = await target.session.close(); + let computedExit = typeof result.exitCode === 'number' ? result.exitCode : -1; + if (reason === ExecExitReason.CANCELLED && (!Number.isFinite(computedExit) || computedExit < 0)) { + computedExit = 0; + } + const stdoutTail = utf8Tail(result.stdout, target.exitTailBytes); + const stderrTail = utf8Tail(result.stderr, target.exitTailBytes); + const exitMessage = create(ExecExitSchema, { + executionId: target.executionId, + exitCode: computedExit, + killed: target.killed, + reason: target.reason, + stdoutTail, + stderrTail, + finishedAt: timestampFromDate(new Date()), + }); + writeResponse(create(ExecResponseSchema, { event: { case: 'exit', value: exitMessage } })); + } catch (error) { + writeResponse( + create(ExecResponseSchema, { + event: { + case: 'error', + value: createRunnerError( + 'exec_close_failed', + error instanceof Error ? error.message : String(error), + false, + ), + }, + }), + ); + } finally { + closeResponses(); + } + }; + + const handleTimeout = async (target: ExecutionContext, reason: ExecExitReason) => { + if (target.finished || target.cancelRequested) return; + target.reason = reason; + const terminationReason = reason === ExecExitReason.IDLE_TIMEOUT ? 'idle_timeout' : 'timeout'; + try { + await target.session.terminateProcessGroup(terminationReason); + target.killed = true; + } catch (terminateErr) { + target.killed = false; + console.warn('Failed to terminate exec process group on timeout', { + executionId: target.executionId, + containerId: target.targetId, + reason, + error: terminateErr instanceof Error ? terminateErr.message : terminateErr, + }); + } + try { + await target.finish?.(reason, target.killed); + } catch { + // finish already emits structured error; swallow here + } + }; + + const handleStart = async (start: ExecStartRequest) => { + if (ctx) { + writeResponse( + create(ExecResponseSchema, { + event: { + case: 'error', + value: createRunnerError('exec_already_started', 'duplicate exec start received', false), + }, + }), + ); + return; + } + const command = start.commandArgv.length > 0 ? start.commandArgv : start.commandShell; + if (!command || (Array.isArray(command) && command.length === 0)) { + writeResponse( + create(ExecResponseSchema, { + event: { case: 'error', value: createRunnerError('invalid_command', 'command required', false) }, + }), + ); + closeResponses(); + return; + } + const exitTailBytes = (() => { + const requested = start.options?.exitTailBytes ? Number(start.options.exitTailBytes) : DEFAULT_EXIT_TAIL_BYTES; + if (!Number.isFinite(requested) || requested <= 0) return 0; + return Math.min(requested, MAX_EXIT_TAIL_BYTES); + })(); + try { + const session = await opts.containers.openInteractiveExec(start.targetId, command, { + workdir: start.options?.workdir || undefined, + env: start.options?.env?.length + ? Object.fromEntries( + start.options.env.map(({ name, value }: { name: string; value: string }) => [name, value] as [ + string, + string, + ]), + ) + : undefined, + tty: start.options?.tty ?? false, + demuxStderr: start.options?.separateStderr ?? true, + }); + const timeoutMs = coerceDuration(start.options?.timeoutMs); + const idleTimeoutMs = coerceDuration(start.options?.idleTimeoutMs); + const now = new Date(); + const context: ExecutionContext = { + executionId: session.execId, + targetId: start.targetId, + requestId: start.requestId, + session, + startedAt: now, + stdoutSeq: 0n, + stderrSeq: 0n, + exitTailBytes, + killOnTimeout: start.options?.killOnTimeout ?? false, + timeoutMs, + idleTimeoutMs, + finished: false, + cancelRequested: false, + timers: {}, + reason: ExecExitReason.COMPLETED, + killed: false, + }; + ctx = context; + context.finish = (reason: ExecExitReason, killed?: boolean) => finish(context, reason, killed); + activeExecutions.set(context.executionId, context); + + const armIdleTimer = () => { + if (!context.idleTimeoutMs || context.idleTimeoutMs <= 0) return; + if (context.finished || context.cancelRequested) return; + if (context.timers.idle) { + clearTimeout(context.timers.idle); + } + context.timers.idle = setTimeout(() => { + if (context.finished || context.cancelRequested) return; + void handleTimeout(context, ExecExitReason.IDLE_TIMEOUT); + }, context.idleTimeoutMs); + }; + + let completionInFlight = false; + const armCompletionCheck = () => { + if (context.finished || context.cancelRequested) return; + if (context.timers.completion) return; + context.timers.completion = setInterval(() => { + if (context.finished || context.cancelRequested || completionInFlight) return; + completionInFlight = true; + void (async () => { + try { + const details = await context.session.inspect(); + if (!details?.Running) { + await finish(context, context.reason, context.killed); + } + } catch (error) { + console.warn('Failed to inspect exec status', { + executionId: context.executionId, + containerId: context.targetId, + error: error instanceof Error ? error.message : error, + }); + await finish(context, ExecExitReason.RUNNER_ERROR, context.killed); + } finally { + completionInFlight = false; + } + })(); + }, 1000); + }; + + if (context.timeoutMs && context.timeoutMs > 0) { + context.timers.timeout = setTimeout(() => { + if (context.finished || context.cancelRequested) return; + void handleTimeout(context, ExecExitReason.TIMEOUT); + }, context.timeoutMs); + } + const started = create(ExecStartedSchema, { + executionId: context.executionId, + startedAt: timestampFromDate(now), + }); + writeResponse(create(ExecResponseSchema, { event: { case: 'started', value: started } })); + + if (context.idleTimeoutMs && context.idleTimeoutMs > 0) { + armIdleTimer(); + } + armCompletionCheck(); + session.stdout.on('data', (chunk: Buffer) => { + if (!ctx || ctx.finished) return; + ctx.stdoutSeq += 1n; + const output = create(ExecOutputSchema, { + seq: ctx.stdoutSeq, + data: chunk, + ts: timestampFromDate(new Date()), + }); + writeResponse(create(ExecResponseSchema, { event: { case: 'stdout', value: output } })); + armIdleTimer(); + }); + session.stderr?.on('data', (chunk: Buffer) => { + if (!ctx || ctx.finished) return; + ctx.stderrSeq += 1n; + const output = create(ExecOutputSchema, { + seq: ctx.stderrSeq, + data: chunk, + ts: timestampFromDate(new Date()), + }); + writeResponse(create(ExecResponseSchema, { event: { case: 'stderr', value: output } })); + armIdleTimer(); + }); + + const finalize = () => { + if (ctx) void finish(ctx, ctx.reason, ctx.killed); + }; + + session.stdout.once('end', finalize); + session.stdout.once('close', finalize); + session.stderr?.once('end', finalize); + session.stderr?.once('close', finalize); + } catch (error) { + writeResponse( + create(ExecResponseSchema, { + event: { + case: 'error', + value: createRunnerError( + 'exec_start_failed', + error instanceof Error ? error.message : String(error), + false, + ), + }, + }), + ); + closeResponses(); + } + }; + + const handleRequest = async (req: ExecRequest) => { + if (!req?.msg?.case) return; + if (req.msg.case === 'start') { + await handleStart(req.msg.value); + return; + } + + if (!ctx) { + writeResponse( + create(ExecResponseSchema, { + event: { + case: 'error', + value: createRunnerError('exec_not_started', 'exec start required before streaming', false), + }, + }), + ); + closeResponses(); + return; + } + + const session = ctx.session; + + if (req.msg.case === 'stdin') { + const stdin = req.msg.value; + if (stdin.data && stdin.data.length > 0) { + session.stdin.write(Buffer.from(stdin.data)); + } + if (stdin.eof) { + session.stdin.end(); + } + return; + } + + if (req.msg.case === 'resize') { + try { + await opts.containers.resizeExec(ctx.executionId, { + cols: req.msg.value.cols, + rows: req.msg.value.rows, + }); + } catch (error) { + writeResponse( + create(ExecResponseSchema, { + event: { + case: 'error', + value: createRunnerError( + 'exec_resize_failed', + error instanceof Error ? error.message : String(error), + false, + ), + }, + }), + ); + } + } + }; + + const handleRequests = (async () => { + try { + for await (const req of requests) { + if (closed) break; + await handleRequest(req); + if (closed || ctx?.finished) break; + } + if (!ctx || closed) { + closeResponses(); + return; + } + if (ctx.finished) { + closeResponses(); + return; + } + return; + } catch { + if (!ctx || ctx.finished || closed) { + closeResponses(); + return; + } + clearExecutionTimers(ctx); + await finish(ctx, ExecExitReason.RUNNER_ERROR, ctx.killed); + } + })(); + + const onAbort = () => { + if (!ctx || ctx.finished || closed) return; + ctx.cancelRequested = true; + clearExecutionTimers(ctx); + void finish(ctx, ExecExitReason.CANCELLED, ctx.killed); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + + try { + for await (const response of responseQueue.iterate()) { + yield response; + } + } finally { + context.signal.removeEventListener('abort', onAbort); + closeResponses(); + await handleRequests.catch(() => undefined); + } + }, + cancelExecution: async (request, context) => { + requireAuth(context, 'cancelExecution'); + const ctx = activeExecutions.get(request.executionId); + if (!ctx) { + return create(CancelExecutionResponseSchema, { cancelled: false }); + } + ctx.cancelRequested = true; + clearExecutionTimers(ctx); + if (ctx.finished) { + return create(CancelExecutionResponseSchema, { cancelled: true }); + } + ctx.finish?.(ExecExitReason.CANCELLED, request.force).catch(() => { + // finish already emits structured error; swallow here + }); + return create(CancelExecutionResponseSchema, { cancelled: true }); + }, + }); + + return createHttp2Server(connectNodeAdapter({ routes })); +} diff --git a/packages/docker-runner/src/service/main.ts b/packages/docker-runner/src/service/main.ts new file mode 100644 index 000000000..23ea08e3c --- /dev/null +++ b/packages/docker-runner/src/service/main.ts @@ -0,0 +1,64 @@ +import './env'; + +import { ContainerService, NonceCache } from '..'; +import { loadRunnerConfig } from './config'; +import { createRunnerGrpcServer } from './grpc/server'; + +async function bootstrap(): Promise { + try { + const config = loadRunnerConfig(); + process.env.DOCKER_SOCKET = config.dockerSocket; + if (!process.env.LOG_LEVEL && config.logLevel) { + process.env.LOG_LEVEL = config.logLevel; + } + + const containers = new ContainerService(); + const nonceCache = new NonceCache({ ttlMs: config.signatureTtlMs }); + const grpcServer = createRunnerGrpcServer({ config, containers, nonceCache }); + const grpcAddress = await new Promise((resolve, reject) => { + const onError = (err: Error) => { + grpcServer.off('error', onError); + reject(err); + }; + grpcServer.once('error', onError); + grpcServer.listen(config.grpcPort, config.grpcHost, () => { + grpcServer.off('error', onError); + const address = grpcServer.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind docker-runner server')); + return; + } + resolve(`${config.grpcHost}:${address.port}`); + }); + }); + console.info(`[docker-runner] gRPC server listening on ${grpcAddress}`); + + let shuttingDown = false; + const shutdown = async (signal: NodeJS.Signals | 'unknown') => { + if (shuttingDown) return; + shuttingDown = true; + console.info(`[docker-runner] shutting down (${signal ?? 'unknown'})`); + await new Promise((resolve) => { + grpcServer.close((err) => { + if (err) { + console.error('[docker-runner] failed to shutdown gRPC server', err); + } + resolve(); + }); + }); + process.exit(0); + }; + + process.on('SIGINT', (signal) => { + void shutdown(signal); + }); + process.on('SIGTERM', (signal) => { + void shutdown(signal); + }); + } catch (error) { + console.error('docker-runner failed to start', error); + process.exit(1); + } +} + +void bootstrap(); diff --git a/packages/platform-server/__tests__/containers.delete.integration.test.ts b/packages/platform-server/__tests__/containers.delete.integration.test.ts index 0fd7a3b56..f236c76a8 100644 --- a/packages/platform-server/__tests__/containers.delete.integration.test.ts +++ b/packages/platform-server/__tests__/containers.delete.integration.test.ts @@ -23,7 +23,6 @@ import { TerminalSessionsService } from '../src/infra/container/terminal.session import { ContainerThreadTerminationService } from '../src/infra/container/containerThreadTermination.service'; import { ContainerEventProcessor } from '../src/infra/container/containerEvent.processor'; import { registerTestConfig, clearTestConfig } from './helpers/config'; -import { createDockerClientStub } from './helpers/dockerClient.stub'; import type { Prisma, PrismaClient } from '@prisma/client'; import { DockerRunnerStatusService } from '../src/infra/container/dockerRunnerStatus.service'; import { DockerRunnerConnectivityMonitor } from '../src/infra/container/dockerRunnerConnectivity.monitor'; @@ -119,6 +118,33 @@ const createLifecycleStub = () => ({ sweep: vi.fn().mockResolvedValue(undefined), }); +const createDockerClientStub = (): DockerClient => ({ + touchLastUsed: vi.fn().mockResolvedValue(undefined), + ensureImage: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue({ id: 'dummy', stop: vi.fn(), remove: vi.fn() }), + execContainer: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }), + openInteractiveExec: vi.fn().mockResolvedValue({ + stdin: null, + stdout: null, + stderr: null, + close: vi.fn(), + inspect: vi.fn().mockResolvedValue({ Running: false, ExitCode: 0 }), + } as any), + streamContainerLogs: vi.fn().mockResolvedValue({ close: vi.fn() } as any), + resizeExec: vi.fn().mockResolvedValue(undefined), + stopContainer: vi.fn().mockResolvedValue(undefined), + removeContainer: vi.fn().mockResolvedValue(undefined), + getContainerLabels: vi.fn().mockResolvedValue(undefined), + getContainerNetworks: vi.fn().mockResolvedValue([]), + findContainersByLabels: vi.fn().mockResolvedValue([]), + listContainersByVolume: vi.fn().mockResolvedValue([]), + removeVolume: vi.fn().mockResolvedValue(undefined), + findContainerByLabels: vi.fn().mockResolvedValue(undefined), + putArchive: vi.fn().mockResolvedValue(undefined), + inspectContainer: vi.fn().mockResolvedValue({} as any), + getEventsStream: vi.fn().mockResolvedValue({ on: vi.fn(), off: vi.fn() } as any), +} as unknown as DockerClient); + const createContainerRegistryStub = () => ({ ensureIndexes: vi.fn().mockResolvedValue(undefined), registerStart: vi.fn(), diff --git a/packages/platform-server/__tests__/helpers/docker.e2e.ts b/packages/platform-server/__tests__/helpers/docker.e2e.ts index 570ab2eb3..78e8c16a5 100644 --- a/packages/platform-server/__tests__/helpers/docker.e2e.ts +++ b/packages/platform-server/__tests__/helpers/docker.e2e.ts @@ -4,24 +4,19 @@ import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; -import { credentials, Metadata } from '@grpc/grpc-js'; import { create } from '@bufbuild/protobuf'; +import { createClient, type Client, type Interceptor } from '@connectrpc/connect'; +import { createGrpcTransport, Http2SessionManager } from '@connectrpc/connect-node'; +import type { Http2Server } from 'node:http2'; -import { NonceCache, buildAuthHeaders } from '../../src/infra/container/auth'; -import { RunnerServiceGrpcClient, RUNNER_SERVICE_READY_PATH } from '../../src/proto/grpc.js'; -import { ReadyRequestSchema } from '../../src/proto/gen/agynio/api/runner/v1/runner_pb.js'; +import { createRunnerGrpcServer } from '../../../docker-runner/src/service/grpc/server'; +import { ContainerService, NonceCache, buildAuthHeaders } from '../../../docker-runner/src'; +import { ReadyRequestSchema, RunnerService } from '../../src/proto/gen/agynio/api/runner/v1/runner_pb.js'; -export const RUNNER_SECRET = process.env.DOCKER_RUNNER_SHARED_SECRET ?? ''; +export const RUNNER_SECRET = 'docker-e2e-secret'; export const DEFAULT_SOCKET = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; export const hasTcpDocker = Boolean(process.env.DOCKER_HOST); export const socketMissing = !fs.existsSync(DEFAULT_SOCKET); -const runnerHost = process.env.DOCKER_RUNNER_GRPC_HOST ?? process.env.DOCKER_RUNNER_HOST; -const runnerPort = process.env.DOCKER_RUNNER_GRPC_PORT ?? process.env.DOCKER_RUNNER_PORT; -export const runnerAddress = - process.env.DOCKER_RUNNER_GRPC_ADDRESS ?? (runnerHost && runnerPort ? `${runnerHost}:${runnerPort}` : undefined); -export const runnerAddressMissing = !runnerAddress; -export const runnerSecretMissing = !RUNNER_SECRET; -const readinessNonceCache = new NonceCache(); export type RunnerHandle = { grpcAddress: string; @@ -33,21 +28,165 @@ export type PostgresHandle = { stop: () => Promise; }; -export async function startDockerRunner(): Promise { - if (!runnerAddress || !RUNNER_SECRET) { - throw new Error('DOCKER_RUNNER_GRPC_ADDRESS and DOCKER_RUNNER_SHARED_SECRET are required to run docker e2e tests.'); +type RunnerServiceClient = Client; + +export async function startDockerRunner(socketPath: string): Promise { + const grpcPort = await getAvailablePort(); + const config = { + grpcHost: '127.0.0.1', + grpcPort, + sharedSecret: RUNNER_SECRET, + signatureTtlMs: 60_000, + dockerSocket: socketPath, + logLevel: 'error', + } as const; + + const previousSocket = process.env.DOCKER_SOCKET; + if (socketPath) { + process.env.DOCKER_SOCKET = socketPath; + } else { + delete process.env.DOCKER_SOCKET; + } + + const containers = new ContainerService(); + const nonceCache = new NonceCache({ ttlMs: config.signatureTtlMs }); + const server = createRunnerGrpcServer({ config, containers, nonceCache }); + const grpcAddress = await bindRunnerServer(server, config.grpcHost, config.grpcPort); + const { client, sessionManager } = createRunnerClient(grpcAddress, RUNNER_SECRET); + + try { + await waitForRunnerReady(client); + } catch (error) { + sessionManager.abort(); + await shutdownRunnerServer(server); + if (previousSocket !== undefined) process.env.DOCKER_SOCKET = previousSocket; + else delete process.env.DOCKER_SOCKET; + throw error; + } finally { + sessionManager.abort(); } - await waitForRunnerReadyOnAddress(runnerAddress, RUNNER_SECRET); + return { - grpcAddress: runnerAddress, - close: async () => undefined, + grpcAddress, + close: async () => { + await shutdownRunnerServer(server); + if (previousSocket !== undefined) process.env.DOCKER_SOCKET = previousSocket; + else delete process.env.DOCKER_SOCKET; + }, }; } -async function waitForRunnerReady(client: RunnerServiceGrpcClient, secret: string): Promise { +export async function startDockerRunnerProcess(socketPath: string): Promise { + const grpcPort = await getAvailablePort(); + const repoRoot = path.resolve(__dirname, '..', '..', '..', '..'); + const runnerEntry = path.resolve(repoRoot, 'packages', 'docker-runner', 'src', 'service', 'main.ts'); + const tsxBin = path.resolve(repoRoot, 'node_modules', '.bin', 'tsx'); + if (!fs.existsSync(tsxBin)) { + throw new Error(`tsx binary not found at ${tsxBin}`); + } + const env: NodeJS.ProcessEnv = { + ...process.env, + DOCKER_RUNNER_GRPC_HOST: '127.0.0.1', + DOCKER_RUNNER_PORT: String(grpcPort), + DOCKER_RUNNER_SHARED_SECRET: RUNNER_SECRET, + DOCKER_RUNNER_LOG_LEVEL: 'error', + }; + delete env.DOCKER_RUNNER_GRPC_PORT; + if (socketPath) { + env.DOCKER_SOCKET = socketPath; + } else { + delete env.DOCKER_SOCKET; + } + + const child = spawn(tsxBin, [runnerEntry], { + cwd: repoRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (chunk) => { + process.stdout.write(`[docker-runner] ${chunk}`); + }); + child.stderr?.on('data', (chunk) => { + process.stderr.write(`[docker-runner] ${chunk}`); + }); + + let exitHandler: ((code: number | null, signal: NodeJS.Signals | null) => void) | null = null; + let errorHandler: ((err: Error) => void) | null = null; + const exitPromise = new Promise((_, reject) => { + exitHandler = (code, signal) => { + reject(new Error(`docker-runner exited before readiness (code=${code ?? 0}, signal=${signal ?? 'none'})`)); + }; + errorHandler = (err) => reject(err); + child.once('exit', exitHandler); + child.once('error', errorHandler); + }); + + try { + await Promise.race([ + waitForRunnerReadyOnAddress(`127.0.0.1:${grpcPort}`, RUNNER_SECRET), + exitPromise, + ]); + } catch (error) { + child.kill('SIGTERM'); + throw error; + } finally { + if (exitHandler) child.off('exit', exitHandler); + if (errorHandler) child.off('error', errorHandler); + } + + return { + grpcAddress: `127.0.0.1:${grpcPort}`, + close: async () => { + if (child.exitCode !== null || child.signalCode) return; + await new Promise((resolve) => { + child.once('exit', () => resolve()); + child.kill('SIGTERM'); + }); + }, + }; +} + +function createRunnerClient(address: string, secret: string): { client: RunnerServiceClient; sessionManager: Http2SessionManager } { + const baseUrl = normalizeRunnerBaseUrl(address); + const sessionManager = new Http2SessionManager(baseUrl); + const transport = createGrpcTransport({ + baseUrl, + interceptors: [createRunnerAuthInterceptor(secret)], + sessionManager, + }); + return { client: createClient(RunnerService, transport), sessionManager }; +} + +async function bindRunnerServer(server: Http2Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.off('error', onError); + reject(err); + }; + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind docker-runner server')); + return; + } + resolve(`${host}:${address.port}`); + }); + }); +} + +async function shutdownRunnerServer(server: Http2Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +async function waitForRunnerReady(client: RunnerServiceClient): Promise { await waitFor(async () => { try { - await callRunnerReady(client, secret); + await callRunnerReady(client); return true; } catch { return false; @@ -56,37 +195,34 @@ async function waitForRunnerReady(client: RunnerServiceGrpcClient, secret: strin } async function waitForRunnerReadyOnAddress(address: string, secret: string): Promise { - const client = new RunnerServiceGrpcClient(address, credentials.createInsecure()); + const { client, sessionManager } = createRunnerClient(address, secret); try { - await waitForRunnerReady(client, secret); + await waitForRunnerReady(client); } finally { - client.close(); + sessionManager.abort(); } } -function callRunnerReady(client: RunnerServiceGrpcClient, secret: string): Promise { +function callRunnerReady(client: RunnerServiceClient): Promise { const request = create(ReadyRequestSchema, {}); - const metadata = authMetadata(secret, RUNNER_SERVICE_READY_PATH); - return new Promise((resolve, reject) => { - client.ready(request, metadata, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); + return client.ready(request); } -function authMetadata(secret: string, path: string): Metadata { - const nonce = randomUUID(); - readinessNonceCache.add(nonce); - const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret, nonce }); - const metadata = new Metadata(); - for (const [key, value] of Object.entries(headers)) { - metadata.set(key, value); - } - return metadata; +function createRunnerAuthInterceptor(secret: string): Interceptor { + return (next) => async (req) => { + const path = new URL(req.url).pathname; + const headers = buildAuthHeaders({ method: req.requestMethod, path, body: '', secret }); + for (const [key, value] of Object.entries(headers)) { + req.header.set(key, value); + } + return next(req); + }; +} + +function normalizeRunnerBaseUrl(address: string): string { + if (/^https?:\/\//i.test(address)) return address; + if (/^grpc:\/\//i.test(address)) return `http://${address.slice('grpc://'.length)}`; + return `http://${address}`; } export async function startPostgres(): Promise { diff --git a/packages/platform-server/__tests__/infra/runnerGrpc.client.test.ts b/packages/platform-server/__tests__/infra/runnerGrpc.client.test.ts index 43c5e843a..4192bc580 100644 --- a/packages/platform-server/__tests__/infra/runnerGrpc.client.test.ts +++ b/packages/platform-server/__tests__/infra/runnerGrpc.client.test.ts @@ -1,10 +1,22 @@ import { describe, expect, it, vi } from 'vitest'; -import { EventEmitter } from 'node:events'; -import type { ClientDuplexStream } from '@grpc/grpc-js'; -import { Metadata, status } from '@grpc/grpc-js'; -import { NonceCache, verifyAuthHeaders } from '../../src/infra/container/auth'; -import { RUNNER_SERVICE_TOUCH_WORKLOAD_PATH } from '../../src/proto/grpc.js'; -import type { RunnerServiceGrpcClientInstance } from '../../src/proto/grpc.js'; +import { + Code, + ConnectError, + createContextValues, + type Interceptor, + type UnaryRequest, + type UnaryResponse, + type Client, +} from '@connectrpc/connect'; +import { NonceCache, verifyAuthHeaders } from '@agyn/docker-runner'; +import { + ExecRequest, + ExecResponse, + TouchWorkloadRequestSchema, + TouchWorkloadResponseSchema, + RunnerService, +} from '../../src/proto/gen/agynio/api/runner/v1/runner_pb.js'; +import { create } from '@bufbuild/protobuf'; import { RunnerGrpcClient, @@ -14,43 +26,51 @@ import { } from '../../src/infra/container/runnerGrpc.client'; import { ExecTimeoutError } from '../../src/utils/execTimeout'; -class MockClientStream extends EventEmitter { - write = vi.fn((_chunk: Req) => true); - end = vi.fn(() => this); - cancel = vi.fn(() => undefined); -} +const runnerServicePath = (method: keyof typeof RunnerService.method): string => + `/${RunnerService.typeName}/${RunnerService.method[method].name}`; + +type RunnerServiceClient = Client; +type RunnerGrpcClientPrivate = { + createAuthInterceptor: () => Interceptor; +}; describe('RunnerGrpcClient', () => { it('sends signed runner metadata on touchLastUsed calls', async () => { const client = new RunnerGrpcClient({ address: 'grpc://runner', sharedSecret: 'test-secret' }); - const captured: { metadata?: Metadata } = {}; - - const touchStub = vi.fn((_: unknown, metadata: Metadata, maybeOptions?: unknown, maybeCallback?: (err: Error | null) => void) => { - const callback = typeof maybeOptions === 'function' ? maybeOptions : maybeCallback; - if (typeof callback !== 'function') throw new Error('callback missing'); - captured.metadata = metadata; - callback(null); - }); - - (client as unknown as { client: { touchWorkload: typeof touchStub } }).client = { - touchWorkload: touchStub, - } as { touchWorkload: typeof touchStub }; - - await client.touchLastUsed('container-123'); - - expect(touchStub).toHaveBeenCalledTimes(1); - expect(captured.metadata).toBeInstanceOf(Metadata); - - const headers: Record = {}; - const metadataMap = captured.metadata?.getMap() ?? {}; - for (const [key, value] of Object.entries(metadataMap)) { - headers[key] = Buffer.isBuffer(value) ? value.toString('utf8') : String(value); + const interceptor = (client as unknown as RunnerGrpcClientPrivate).createAuthInterceptor(); + const headers = new Headers(); + const path = runnerServicePath('touchWorkload'); + const request: UnaryRequest = { + stream: false, + service: RunnerService, + method: RunnerService.method.touchWorkload, + requestMethod: 'POST', + url: `http://runner${path}`, + signal: new AbortController().signal, + header: headers, + contextValues: createContextValues(), + message: create(TouchWorkloadRequestSchema, { workloadId: 'container-123' }), + }; + const next = vi.fn(async (): Promise => ({ + stream: false, + service: RunnerService, + method: RunnerService.method.touchWorkload, + header: new Headers(), + trailer: new Headers(), + message: create(TouchWorkloadResponseSchema, {}), + })); + + await interceptor(next)(request); + + const capturedHeaders: Record = {}; + for (const [key, value] of headers.entries()) { + capturedHeaders[key] = value; } const verification = verifyAuthHeaders({ - headers, + headers: capturedHeaders, method: 'POST', - path: RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, + path, body: '', secret: 'test-secret', nonceCache: new NonceCache(), @@ -60,13 +80,13 @@ describe('RunnerGrpcClient', () => { it('sanitizes infra details from gRPC errors', async () => { const client = new RunnerGrpcClient({ address: 'grpc://runner', sharedSecret: 'secret' }); - const error = Object.assign(new Error('Deadline exceeded after 305.002s,LB pick: 0.001s,remote_addr=172.21.0.3:50051'), { - code: status.DEADLINE_EXCEEDED, - details: 'Deadline exceeded after 305.002s,LB pick: 0.001s,remote_addr=172.21.0.3:50051', - }); + const error = new ConnectError( + 'Deadline exceeded after 305.002s,LB pick: 0.001s,remote_addr=172.21.0.3:50051', + Code.DeadlineExceeded, + ); const translated = (client as unknown as { - translateServiceError(err: Error, context?: { path?: string }): DockerRunnerRequestError; + translateServiceError(err: unknown, context?: { path?: string }): DockerRunnerRequestError; }).translateServiceError(error, { path: '/docker.runner.RunnerService/TouchWorkload' }); expect(translated).toBeInstanceOf(DockerRunnerRequestError); @@ -83,31 +103,34 @@ describe('RunnerGrpcClient', () => { describe('RunnerGrpcExecClient', () => { it('rejects exec calls with ExecTimeoutError when the stream exceeds its deadline', async () => { - const stream = new MockClientStream(); - const execStub = vi.fn( - () => stream as unknown as ClientDuplexStream, - ); + const captured: { request?: ExecRequest } = {}; + const execStub = vi.fn((requests: AsyncIterable) => { + const capturePromise = (async () => { + const iterator = requests[Symbol.asyncIterator](); + const first = await iterator.next(); + captured.request = first.value; + })(); + async function* responses(): AsyncIterable { + await capturePromise; + throw new ConnectError( + 'Deadline exceeded after 1500ms,remote_addr=10.0.0.2:50051', + Code.DeadlineExceeded, + ); + } + return responses(); + }); const execClient = new RunnerGrpcExecClient({ address: 'grpc://runner', sharedSecret: 'secret', - client: { exec: execStub } as unknown as RunnerServiceGrpcClientInstance, + client: { exec: execStub, cancelExecution: vi.fn() } as unknown as RunnerServiceClient, }); const execPromise = execClient.exec('container-1', ['echo', 'hi'], { timeoutMs: 1_500 }); - const error = Object.assign(new Error('Deadline exceeded after 1500ms,remote_addr=10.0.0.2:50051'), { - code: status.DEADLINE_EXCEEDED, - details: 'Deadline exceeded after 1500ms,remote_addr=10.0.0.2:50051', - }); - - queueMicrotask(() => { - stream.emit('error', error); - }); - const failure = await execPromise.catch((err) => err); expect(execStub).toHaveBeenCalledTimes(1); - expect(stream.write).toHaveBeenCalledWith( + expect(captured.request).toEqual( expect.objectContaining({ msg: expect.objectContaining({ case: 'start' }), }), diff --git a/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts b/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts index 409fe5150..3ebf53936 100644 --- a/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts +++ b/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts @@ -1,29 +1,33 @@ import { setTimeout as delay } from 'node:timers/promises'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Http2SessionManager } from '@connectrpc/connect-node'; import { RunnerGrpcClient } from '../src/infra/container/runnerGrpc.client'; import { + DEFAULT_SOCKET, RUNNER_SECRET, hasTcpDocker, - runnerAddressMissing, - runnerSecretMissing, socketMissing, startDockerRunner, type RunnerHandle, } from './helpers/docker.e2e'; -const shouldSkip = process.env.SKIP_RUNNER_EXEC_E2E === '1' || runnerAddressMissing || runnerSecretMissing; +const shouldSkip = process.env.SKIP_RUNNER_EXEC_E2E === '1'; const describeOrSkip = shouldSkip || (socketMissing && !hasTcpDocker) ? describe.skip : describe; describeOrSkip('runner gRPC exec cancellation integration', () => { let runner: RunnerHandle; let dockerClient: RunnerGrpcClient; + let sessionManager: Http2SessionManager | null = null; let containerId: string | null = null; beforeAll(async () => { - runner = await startDockerRunner(); - dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET }); + const socketPath = socketMissing && hasTcpDocker ? '' : DEFAULT_SOCKET; + runner = await startDockerRunner(socketPath); + const baseUrl = runner.grpcAddress.startsWith('http') ? runner.grpcAddress : `http://${runner.grpcAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET, sessionManager }); }, 120_000); afterAll(async () => { @@ -39,6 +43,8 @@ describeOrSkip('runner gRPC exec cancellation integration', () => { // ignore cleanup failures } } + sessionManager?.abort(); + sessionManager = null; await runner.close(); }); diff --git a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts index 3237e38d3..f2b8e60a8 100644 --- a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -1,28 +1,30 @@ import { describe, expect, it, vi } from 'vitest'; -import { Metadata, status, type CallOptions, type ServiceError } from '@grpc/grpc-js'; +import { Code, ConnectError, type CallOptions, type Client } from '@connectrpc/connect'; import { HttpStatus } from '@nestjs/common'; import { ListAgentsRequestSchema, type ListAgentsRequest, type ListAgentsResponse, + TeamsService, } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb.js'; import { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; -import { - TEAMS_SERVICE_LIST_AGENTS_PATH, - type TeamsServiceGrpcClientInstance, -} from '../../src/proto/teams-grpc.js'; const DEFAULT_ERROR_MESSAGE = 'Teams service request failed'; +const teamsServicePath = (method: keyof typeof TeamsService.method): string => + `/${TeamsService.typeName}/${TeamsService.method[method].name}`; + +type TeamsServiceClient = Client; + type TeamsGrpcClientPrivate = { - grpcStatusToHttpStatus: (grpcCode: status) => HttpStatus; - grpcStatusToErrorCode: (grpcCode: status) => string; - extractServiceErrorMessage: (error: ServiceError) => string; + grpcStatusToHttpStatus: (grpcCode: Code) => HttpStatus; + grpcStatusToErrorCode: (grpcCode: Code) => string; + extractServiceErrorMessage: (error: ConnectError) => string; call: ( path: string, schema: unknown, request: Req, - method: keyof TeamsServiceGrpcClientInstance, + method: keyof TeamsServiceClient, timeoutMs?: number, ) => Promise; }; @@ -33,22 +35,22 @@ describe('TeamsGrpcClient', () => { }); it.each([ - [status.INVALID_ARGUMENT, HttpStatus.BAD_REQUEST, 'teams_invalid_argument'], - [status.UNAUTHENTICATED, HttpStatus.UNAUTHORIZED, 'teams_unauthenticated'], - [status.PERMISSION_DENIED, HttpStatus.FORBIDDEN, 'teams_forbidden'], - [status.NOT_FOUND, HttpStatus.NOT_FOUND, 'teams_not_found'], - [status.ABORTED, HttpStatus.CONFLICT, 'teams_conflict'], - [status.ALREADY_EXISTS, HttpStatus.CONFLICT, 'teams_conflict'], - [status.FAILED_PRECONDITION, HttpStatus.PRECONDITION_FAILED, 'teams_failed_precondition'], - [status.RESOURCE_EXHAUSTED, HttpStatus.TOO_MANY_REQUESTS, 'teams_resource_exhausted'], - [status.UNIMPLEMENTED, HttpStatus.NOT_IMPLEMENTED, 'teams_unimplemented'], - [status.INTERNAL, HttpStatus.INTERNAL_SERVER_ERROR, 'teams_internal_error'], - [status.DATA_LOSS, HttpStatus.INTERNAL_SERVER_ERROR, 'teams_data_loss'], - [status.UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE, 'teams_unavailable'], - [status.DEADLINE_EXCEEDED, HttpStatus.GATEWAY_TIMEOUT, 'teams_timeout'], - [status.OUT_OF_RANGE, HttpStatus.BAD_REQUEST, 'teams_grpc_error'], - [status.CANCELLED, 499, 'teams_cancelled'], - [status.UNKNOWN, HttpStatus.BAD_GATEWAY, 'teams_grpc_error'], + [Code.InvalidArgument, HttpStatus.BAD_REQUEST, 'teams_invalid_argument'], + [Code.Unauthenticated, HttpStatus.UNAUTHORIZED, 'teams_unauthenticated'], + [Code.PermissionDenied, HttpStatus.FORBIDDEN, 'teams_forbidden'], + [Code.NotFound, HttpStatus.NOT_FOUND, 'teams_not_found'], + [Code.Aborted, HttpStatus.CONFLICT, 'teams_conflict'], + [Code.AlreadyExists, HttpStatus.CONFLICT, 'teams_conflict'], + [Code.FailedPrecondition, HttpStatus.PRECONDITION_FAILED, 'teams_failed_precondition'], + [Code.ResourceExhausted, HttpStatus.TOO_MANY_REQUESTS, 'teams_resource_exhausted'], + [Code.Unimplemented, HttpStatus.NOT_IMPLEMENTED, 'teams_unimplemented'], + [Code.Internal, HttpStatus.INTERNAL_SERVER_ERROR, 'teams_internal_error'], + [Code.DataLoss, HttpStatus.INTERNAL_SERVER_ERROR, 'teams_data_loss'], + [Code.Unavailable, HttpStatus.SERVICE_UNAVAILABLE, 'teams_unavailable'], + [Code.DeadlineExceeded, HttpStatus.GATEWAY_TIMEOUT, 'teams_timeout'], + [Code.OutOfRange, HttpStatus.BAD_REQUEST, 'teams_grpc_error'], + [Code.Canceled, 499, 'teams_cancelled'], + [Code.Unknown, HttpStatus.BAD_GATEWAY, 'teams_grpc_error'], ])('maps gRPC status %s to HTTP status and error code', (grpc, http, errorCode) => { const client = new TeamsGrpcClient({ address: 'grpc://teams' }); const internal = client as unknown as TeamsGrpcClientPrivate; @@ -60,9 +62,7 @@ describe('TeamsGrpcClient', () => { it('prefers gRPC error details when extracting message', () => { const client = new TeamsGrpcClient({ address: 'grpc://teams' }); const internal = client as unknown as TeamsGrpcClientPrivate; - const error = Object.assign(new Error('fallback message'), { - details: 'detailed message', - }) as ServiceError; + const error = new ConnectError('detailed message', Code.InvalidArgument); expect(internal.extractServiceErrorMessage(error)).toBe('detailed message'); }); @@ -70,9 +70,7 @@ describe('TeamsGrpcClient', () => { it('uses error message when details are blank', () => { const client = new TeamsGrpcClient({ address: 'grpc://teams' }); const internal = client as unknown as TeamsGrpcClientPrivate; - const error = Object.assign(new Error(' fallback message '), { - details: ' ', - }) as ServiceError; + const error = new ConnectError(' fallback message ', Code.Unknown); expect(internal.extractServiceErrorMessage(error)).toBe('fallback message'); }); @@ -80,90 +78,48 @@ describe('TeamsGrpcClient', () => { it('falls back to default message when details and message are empty', () => { const client = new TeamsGrpcClient({ address: 'grpc://teams' }); const internal = client as unknown as TeamsGrpcClientPrivate; - const error = Object.assign(new Error(''), { - details: '', - }) as ServiceError; + const error = new ConnectError('', Code.Unknown); expect(internal.extractServiceErrorMessage(error)).toBe(DEFAULT_ERROR_MESSAGE); }); it('applies the default request timeout to gRPC calls', async () => { - vi.useFakeTimers(); - const now = new Date('2026-03-09T00:00:00.000Z'); - vi.setSystemTime(now); - - try { - const client = new TeamsGrpcClient({ address: 'grpc://teams', requestTimeoutMs: 5_000 }); - const captured: { options?: CallOptions } = {}; - - const listAgentsStub = vi.fn( - ( - _req: ListAgentsRequest, - _metadata: Metadata, - optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: ListAgentsResponse) => void), - maybeCallback?: (err: ServiceError | null, response?: ListAgentsResponse) => void, - ) => { - const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; - const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; - captured.options = options; - callback?.(null, { agents: [], nextPageToken: '' } as ListAgentsResponse); - }, - ); - - (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { - listAgents: listAgentsStub, - } as TeamsServiceGrpcClientInstance; - - await client.listAgents({}); - - expect(listAgentsStub).toHaveBeenCalledTimes(1); - expect(captured.options?.deadline?.getTime()).toBe(now.getTime() + 5_000); - } finally { - vi.useRealTimers(); - } + const client = new TeamsGrpcClient({ address: 'grpc://teams', requestTimeoutMs: 5_000 }); + const captured: { options?: CallOptions } = {}; + + const listAgentsStub = vi.fn(async (_req: ListAgentsRequest, options?: CallOptions) => { + captured.options = options; + return { agents: [], nextPageToken: '' } as ListAgentsResponse; + }); + + (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { + listAgents: listAgentsStub, + } as TeamsServiceClient; + + await client.listAgents({}); + + expect(listAgentsStub).toHaveBeenCalledTimes(1); + expect(captured.options?.timeoutMs).toBe(5_000); }); it('honors per-call timeout overrides', async () => { - vi.useFakeTimers(); - const now = new Date('2026-03-09T00:00:00.000Z'); - vi.setSystemTime(now); - - try { - const client = new TeamsGrpcClient({ address: 'grpc://teams', requestTimeoutMs: 5_000 }); - const captured: { options?: CallOptions } = {}; - - const listAgentsStub = vi.fn( - ( - _req: ListAgentsRequest, - _metadata: Metadata, - optionsOrCallback?: CallOptions | ((err: ServiceError | null, response?: ListAgentsResponse) => void), - maybeCallback?: (err: ServiceError | null, response?: ListAgentsResponse) => void, - ) => { - const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback; - const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; - captured.options = options; - callback?.(null, { agents: [], nextPageToken: '' } as ListAgentsResponse); - }, - ); - - (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { - listAgents: listAgentsStub, - } as TeamsServiceGrpcClientInstance; - - const internal = client as unknown as TeamsGrpcClientPrivate; - - await internal.call( - TEAMS_SERVICE_LIST_AGENTS_PATH, - ListAgentsRequestSchema, - {}, - 'listAgents', - 12_500, - ); - - expect(listAgentsStub).toHaveBeenCalledTimes(1); - expect(captured.options?.deadline?.getTime()).toBe(now.getTime() + 12_500); - } finally { - vi.useRealTimers(); - } + const client = new TeamsGrpcClient({ address: 'grpc://teams', requestTimeoutMs: 5_000 }); + const captured: { options?: CallOptions } = {}; + + const listAgentsStub = vi.fn(async (_req: ListAgentsRequest, options?: CallOptions) => { + captured.options = options; + return { agents: [], nextPageToken: '' } as ListAgentsResponse; + }); + + (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { + listAgents: listAgentsStub, + } as TeamsServiceClient; + + const internal = client as unknown as TeamsGrpcClientPrivate; + + await internal.call(teamsServicePath('listAgents'), ListAgentsRequestSchema, {}, 'listAgents', 12_500); + + expect(listAgentsStub).toHaveBeenCalledTimes(1); + expect(captured.options?.timeoutMs).toBe(12_500); }); }); diff --git a/packages/platform-server/__tests__/terminal.gateway.test.ts b/packages/platform-server/__tests__/terminal.gateway.test.ts index 8c02b61da..217638b26 100644 --- a/packages/platform-server/__tests__/terminal.gateway.test.ts +++ b/packages/platform-server/__tests__/terminal.gateway.test.ts @@ -248,6 +248,7 @@ describe('ContainerTerminalGateway (custom websocket server)', () => { stdout, stderr: undefined, close: closeExec, + inspect: vi.fn().mockResolvedValue({ Running: false, ExitCode: 0 }), execId: 'exec-123', }), resize: vi.fn().mockResolvedValue(undefined), @@ -355,6 +356,7 @@ describe('ContainerTerminalGateway (custom websocket server)', () => { stdout, stderr: undefined, close: closeExec, + inspect: vi.fn().mockResolvedValue({ Running: false, ExitCode: 0 }), execId: 'exec-reconnect', }; }), @@ -444,6 +446,7 @@ describe('ContainerTerminalGateway (custom websocket server)', () => { stdout, stderr: undefined, close: closeExec, + inspect: vi.fn().mockResolvedValue({ Running: false, ExitCode: 0 }), execId: 'exec-final', }), resize: vi.fn().mockResolvedValue(undefined), @@ -544,6 +547,7 @@ describe('ContainerTerminalGateway (custom websocket server)', () => { stdout, stderr: undefined, close: closeExec, + inspect: vi.fn().mockResolvedValue({ Running: false, ExitCode: 0 }), execId: 'exec-mux', }), resize: vi.fn().mockResolvedValue(undefined), diff --git a/packages/platform-server/__tests__/workspace.exec.grpc.test.ts b/packages/platform-server/__tests__/workspace.exec.grpc.test.ts index 5b9ead1e3..6a286de02 100644 --- a/packages/platform-server/__tests__/workspace.exec.grpc.test.ts +++ b/packages/platform-server/__tests__/workspace.exec.grpc.test.ts @@ -1,18 +1,32 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Http2SessionManager } from '@connectrpc/connect-node'; import { RunnerGrpcClient } from '../src/infra/container/runnerGrpc.client'; +import { ContainerService, NonceCache } from '@agyn/docker-runner'; +import type { RunnerConfig } from '../../docker-runner/src/service/config'; +import { createRunnerGrpcServer } from '../../docker-runner/src/service/grpc/server'; +import type { Http2Server } from 'node:http2'; import type { ContainerRegistry } from '../src/infra/container/container.registry'; import { DockerWorkspaceRuntimeProvider } from '../src/workspace/providers/docker.workspace.provider'; const RUNNER_SECRET_OVERRIDE = process.env.DOCKER_RUNNER_SHARED_SECRET_OVERRIDE; const RUNNER_SECRET = RUNNER_SECRET_OVERRIDE ?? process.env.DOCKER_RUNNER_SHARED_SECRET; const RUNNER_ADDRESS_OVERRIDE = process.env.DOCKER_RUNNER_GRPC_ADDRESS; -const RUNNER_HOST = process.env.DOCKER_RUNNER_GRPC_HOST ?? process.env.DOCKER_RUNNER_HOST; -const RUNNER_PORT = process.env.DOCKER_RUNNER_GRPC_PORT ?? process.env.DOCKER_RUNNER_PORT; +const RUNNER_HOST = process.env.DOCKER_RUNNER_GRPC_HOST; +const RUNNER_PORT = process.env.DOCKER_RUNNER_GRPC_PORT; + +const DEFAULT_RUNNER_HOST = 'docker-runner'; +const DEFAULT_RUNNER_PORT = '50051'; const resolvedRunnerAddress = - RUNNER_ADDRESS_OVERRIDE ?? (RUNNER_HOST && RUNNER_PORT ? `${RUNNER_HOST}:${RUNNER_PORT}` : undefined); -const shouldRunTests = Boolean(RUNNER_SECRET && resolvedRunnerAddress); + RUNNER_ADDRESS_OVERRIDE ?? + (RUNNER_HOST && RUNNER_PORT && !(RUNNER_HOST === DEFAULT_RUNNER_HOST && RUNNER_PORT === DEFAULT_RUNNER_PORT) + ? `${RUNNER_HOST}:${RUNNER_PORT}` + : undefined); + +if (!RUNNER_SECRET) { + throw new Error('Docker runner gRPC environment variables are required for workspace exec tests.'); +} const TEST_IMAGE = 'ghcr.io/agynio/devcontainer:latest'; const THREAD_ID = `grpc-exec-${Date.now()}`; const TEST_TIMEOUT_MS = 30_000; @@ -23,28 +37,75 @@ class NoopContainerRegistry { const registry = new NoopContainerRegistry() as unknown as ContainerRegistry; +let grpcServer: Http2Server | undefined; let provider: DockerWorkspaceRuntimeProvider; let runnerClient: RunnerGrpcClient; let workspaceId: string; -const describeRunner = shouldRunTests ? describe : describe.skip; - -describeRunner('DockerWorkspaceRuntimeProvider exec over gRPC runner', () => { - beforeAll(async () => { - runnerClient = new RunnerGrpcClient({ address: resolvedRunnerAddress!, sharedSecret: RUNNER_SECRET! }); - provider = new DockerWorkspaceRuntimeProvider(runnerClient, registry); - - const ensure = await provider.ensureWorkspace( - { threadId: THREAD_ID, role: 'workspace' }, - { image: TEST_IMAGE, ttlSeconds: 600 }, - ); - workspaceId = ensure.workspaceId; - }, TEST_TIMEOUT_MS); - - afterAll(async () => { - if (workspaceId) { - await provider.destroyWorkspace(workspaceId, { force: true }).catch(() => undefined); +let runnerAddress = resolvedRunnerAddress; +let sessionManager: Http2SessionManager | undefined; + +beforeAll(async () => { + if (!runnerAddress) { + const runnerConfig: RunnerConfig = { + grpcHost: '127.0.0.1', + grpcPort: 0, + sharedSecret: RUNNER_SECRET, + signatureTtlMs: 60_000, + dockerSocket: process.env.DOCKER_RUNNER_SOCKET ?? '/var/run/docker.sock', + logLevel: 'info', + }; + + if (!process.env.DOCKER_SOCKET) { + process.env.DOCKER_SOCKET = runnerConfig.dockerSocket; } - }, TEST_TIMEOUT_MS); + + const containers = new ContainerService(); + const nonceCache = new NonceCache({ ttlMs: runnerConfig.signatureTtlMs }); + grpcServer = createRunnerGrpcServer({ config: runnerConfig, containers, nonceCache }); + runnerAddress = await new Promise((resolve, reject) => { + const onError = (err: Error) => { + grpcServer!.off('error', onError); + reject(err); + }; + grpcServer!.once('error', onError); + grpcServer!.listen(0, runnerConfig.grpcHost, () => { + grpcServer!.off('error', onError); + const address = grpcServer!.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind docker-runner server')); + return; + } + resolve(`${runnerConfig.grpcHost}:${address.port}`); + }); + }); + } + + const baseUrl = runnerAddress.startsWith('http') ? runnerAddress : `http://${runnerAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + runnerClient = new RunnerGrpcClient({ address: runnerAddress, sharedSecret: RUNNER_SECRET, sessionManager }); + provider = new DockerWorkspaceRuntimeProvider(runnerClient, registry); + + const ensure = await provider.ensureWorkspace( + { threadId: THREAD_ID, role: 'workspace' }, + { image: TEST_IMAGE, ttlSeconds: 600 }, + ); + workspaceId = ensure.workspaceId; +}, TEST_TIMEOUT_MS); + +afterAll(async () => { + if (workspaceId) { + await provider.destroyWorkspace(workspaceId, { force: true }).catch(() => undefined); + } + sessionManager?.abort(); + sessionManager = undefined; + if (grpcServer) { + await new Promise((resolve) => { + grpcServer!.close(() => resolve()); + }); + } +}, TEST_TIMEOUT_MS); + +describe('DockerWorkspaceRuntimeProvider exec over gRPC runner', () => { it( 'executes non-interactive echo command', async () => { diff --git a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts index a93bd5b00..a849e0d1a 100644 --- a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts +++ b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { randomUUID } from 'node:crypto'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Http2SessionManager } from '@connectrpc/connect-node'; import { DockerWorkspaceRuntimeProvider } from '../../src/workspace/providers/docker.workspace.provider'; import { WorkspaceNode, ContainerProviderStaticConfigSchema } from '../../src/nodes/workspace/workspace.node'; @@ -13,23 +14,23 @@ import { PrismaService } from '../../src/core/services/prisma.service'; import { registerTestConfig, clearTestConfig } from '../helpers/config'; import { RUNNER_SECRET, + DEFAULT_SOCKET, hasTcpDocker, - runnerAddressMissing, - runnerSecretMissing, socketMissing, - startDockerRunner, + startDockerRunnerProcess, startPostgres, runPrismaMigrations, type RunnerHandle, type PostgresHandle, } from '../helpers/docker.e2e'; -const shouldSkip = process.env.SKIP_WORKSPACE_REUSE_E2E === '1' || runnerAddressMissing || runnerSecretMissing; +const shouldSkip = process.env.SKIP_WORKSPACE_REUSE_E2E === '1'; const describeOrSkip = shouldSkip || (socketMissing && !hasTcpDocker) ? describe.skip : describe.sequential; describeOrSkip('Docker workspace reuse lifecycle', () => { let runner: RunnerHandle; let dockerClient: RunnerGrpcClient; + let sessionManager: Http2SessionManager | null = null; let dbHandle: PostgresHandle; let prismaService: PrismaService; let prismaClient: ReturnType; @@ -43,8 +44,11 @@ describeOrSkip('Docker workspace reuse lifecycle', () => { dbHandle = await startPostgres(); await runPrismaMigrations(dbHandle.connectionString); - runner = await startDockerRunner(); - dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET }); + const socketPath = socketMissing && hasTcpDocker ? '' : DEFAULT_SOCKET; + runner = await startDockerRunnerProcess(socketPath); + const baseUrl = runner.grpcAddress.startsWith('http') ? runner.grpcAddress : `http://${runner.grpcAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET, sessionManager }); clearTestConfig(); const [grpcHost, grpcPort] = runner.grpcAddress.split(':'); @@ -105,6 +109,8 @@ describeOrSkip('Docker workspace reuse lifecycle', () => { if (prismaClient) { await prismaClient.$disconnect(); } + sessionManager?.abort(); + sessionManager = null; if (runner) { await runner.close(); } diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 35d00ac98..0b7bd604a 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -21,11 +21,13 @@ "prisma:studio": "prisma studio" }, "dependencies": { - "@agyn/shared": "workspace:*", + "@agyn/docker-runner": "workspace:*", "@agyn/json-schema-to-zod": "workspace:*", "@agyn/llm": "workspace:*", + "@agyn/shared": "workspace:*", "@bufbuild/protobuf": "^2.11.0", - "@grpc/grpc-js": "^1.12.2", + "@connectrpc/connect": "^2.1.1", + "@connectrpc/connect-node": "^2.1.1", "@fastify/cors": "^11.1.0", "@fastify/websocket": "^11.2.0", "@langchain/core": "1.0.1", @@ -54,9 +56,9 @@ "mustache": "^4.2.0", "nestjs-pino": "^4.5.0", "node-fetch-native": "^1.6.7", - "picomatch": "^4.0.2", "openai": "^6.6.0", "p-limit": "^3.1.0", + "picomatch": "^4.0.2", "pino": "^10.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -76,8 +78,8 @@ "@types/json-schema": "^7.0.15", "@types/lodash-es": "^4.17.12", "@types/md5": "^2.3.5", - "@types/node": "^24.5.1", "@types/mustache": "^4.2.5", + "@types/node": "^24.5.1", "@types/semver": "^7.5.8", "@types/tar-stream": "^2.2.3", "@types/ws": "^8.5.11", diff --git a/packages/platform-server/src/infra/container/dockerRunner.types.ts b/packages/platform-server/src/infra/container/dockerRunner.types.ts index bcb18aa8d..ab57211e5 100644 --- a/packages/platform-server/src/infra/container/dockerRunner.types.ts +++ b/packages/platform-server/src/infra/container/dockerRunner.types.ts @@ -62,11 +62,17 @@ export type InteractiveExecOptions = { demuxStderr?: boolean; }; +export type ExecInspectInfo = { + Running?: boolean; + ExitCode?: number | null; +}; + export type InteractiveExecSession = { stdin: NodeJS.WritableStream; stdout: NodeJS.ReadableStream; stderr?: NodeJS.ReadableStream; close: () => Promise; + inspect: () => Promise; execId: string; terminateProcessGroup: (reason: 'timeout' | 'idle_timeout') => Promise; }; diff --git a/packages/platform-server/src/infra/container/runnerGrpc.client.ts b/packages/platform-server/src/infra/container/runnerGrpc.client.ts index b2fb0695a..49c202bc2 100644 --- a/packages/platform-server/src/infra/container/runnerGrpc.client.ts +++ b/packages/platform-server/src/infra/container/runnerGrpc.client.ts @@ -1,98 +1,84 @@ import { randomUUID } from 'node:crypto'; import { PassThrough, Writable } from 'node:stream'; import { - credentials, - Metadata, - status, + Code, + ConnectError, + createClient, type CallOptions, - type ClientDuplexStream, - type ClientReadableStream, - type ServiceError, -} from '@grpc/grpc-js'; + type Client, + type Interceptor, + type Transport, +} from '@connectrpc/connect'; +import { createGrpcTransport, type Http2SessionManager } from '@connectrpc/connect-node'; import { create } from '@bufbuild/protobuf'; import { Logger } from '@nestjs/common'; -import { buildAuthHeaders } from './auth'; -import { ContainerHandle } from './container.handle'; -import type { - ContainerInspectInfo, - ContainerOpts, - DockerEventFilters, - ExecOptions, - ExecResult, - InteractiveExecOptions, - InteractiveExecSession, - LogsStreamOptions, - LogsStreamSession, -} from './dockerRunner.types'; +import { + buildAuthHeaders, + ContainerHandle, + containerOptsToStartWorkloadRequest, + type ContainerInspectInfo, + type ContainerOpts, + type DockerEventFilters, + type ExecInspectInfo, + type ExecOptions, + type ExecResult, + type InteractiveExecOptions, + type InteractiveExecSession, + type LogsStreamOptions, + type LogsStreamSession, +} from '@agyn/docker-runner'; import { CancelExecutionRequestSchema, - CancelExecutionResponse, - ExecError, ExecOptionsSchema, - ExecRequest, ExecRequestSchema, - ExecResponse, ExecStdinSchema, ExecStartRequestSchema, ExecResizeSchema, FindWorkloadsByLabelsRequestSchema, - FindWorkloadsByLabelsResponse, GetWorkloadLabelsRequestSchema, - GetWorkloadLabelsResponse, InspectWorkloadRequestSchema, - InspectWorkloadResponse, ListWorkloadsByVolumeRequestSchema, - ListWorkloadsByVolumeResponse, PutArchiveRequestSchema, ReadyRequestSchema, - ReadyResponse, RemoveVolumeRequestSchema, RemoveWorkloadRequestSchema, - RunnerError, ExecExitReason, - StartWorkloadResponse, StopWorkloadRequestSchema, StreamEventsRequestSchema, - StreamEventsResponse, StreamWorkloadLogsRequestSchema, - StreamWorkloadLogsResponse, TouchWorkloadRequestSchema, EventFilterSchema, WorkloadStatus, - type EventFilter, - type TargetMount, - type StartWorkloadRequest, - type StopWorkloadRequest, - type RemoveWorkloadRequest, - type FindWorkloadsByLabelsRequest, - type ListWorkloadsByVolumeRequest, - type PutArchiveRequest, - type ReadyRequest, - type RemoveVolumeRequest, - type TouchWorkloadRequest, - type GetWorkloadLabelsRequest, - type InspectWorkloadRequest, + RunnerService, +} from '../../proto/gen/agynio/api/runner/v1/runner_pb.js'; +import type { + ExecError, + ExecExit, + ExecRequest, + ExecResponse, + FindWorkloadsByLabelsResponse, + GetWorkloadLabelsResponse, + InspectWorkloadResponse, + ListWorkloadsByVolumeResponse, + ReadyResponse, + RunnerError, + StartWorkloadResponse, + StreamEventsResponse, + StreamWorkloadLogsResponse, + EventFilter, + TargetMount, + StartWorkloadRequest, + StopWorkloadRequest, + RemoveWorkloadRequest, + FindWorkloadsByLabelsRequest, + ListWorkloadsByVolumeRequest, + PutArchiveRequest, + ReadyRequest, + RemoveVolumeRequest, + TouchWorkloadRequest, + GetWorkloadLabelsRequest, + InspectWorkloadRequest, } from '../../proto/gen/agynio/api/runner/v1/runner_pb.js'; -import { - RunnerServiceGrpcClient, - type RunnerServiceGrpcClientInstance, - RUNNER_SERVICE_CANCEL_EXEC_PATH, - RUNNER_SERVICE_EXEC_PATH, - RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH, - RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH, - RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, - RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH, - RUNNER_SERVICE_PUT_ARCHIVE_PATH, - RUNNER_SERVICE_READY_PATH, - RUNNER_SERVICE_REMOVE_VOLUME_PATH, - RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, - RUNNER_SERVICE_START_WORKLOAD_PATH, - RUNNER_SERVICE_STOP_WORKLOAD_PATH, - RUNNER_SERVICE_STREAM_EVENTS_PATH, - RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH, - RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, -} from '../../proto/grpc.js'; -import { containerOptsToStartWorkloadRequest } from './workload.grpc'; import { ExecIdleTimeoutError, ExecTimeoutError } from '../../utils/execTimeout'; import type { DockerClient } from './dockerClient.token'; @@ -100,6 +86,26 @@ export const EXEC_REQUEST_TIMEOUT_SLACK_MS = 5_000; const RUNNER_ERROR_MESSAGE_FALLBACK = 'Runner request failed'; +const normalizeBaseUrl = (address: string): string => { + const trimmed = address.trim(); + if (!trimmed) return ''; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^grpc:\/\//i.test(trimmed)) return `http://${trimmed.slice('grpc://'.length)}`; + return `http://${trimmed}`; +}; + +const runnerServicePath = (method: keyof typeof RunnerService.method): string => + `/${RunnerService.typeName}/${RunnerService.method[method].name}`; + +const createRunnerAuthInterceptor = (sharedSecret: string): Interceptor => (next) => async (req) => { + const path = new URL(req.url, 'http://localhost').pathname; + const headers = buildAuthHeaders({ method: req.requestMethod, path, body: '', secret: sharedSecret }); + for (const [key, value] of Object.entries(headers)) { + req.header.set(key, value); + } + return next(req); +}; + const INFRA_DETAIL_PATTERNS = [ /remote_addr\s*=[^,]+/gi, /\bLB pick:[^,]+/gi, @@ -122,9 +128,9 @@ const sanitizeInfraMessage = (message: string): string => { return sanitized.replace(/\s{2,}/g, ' ').trim(); }; -const extractSanitizedServiceErrorMessage = (error: ServiceError): { sanitized: string; raw: string } => { - const rawDetails = typeof error.details === 'string' ? error.details : ''; - const rawMessage = typeof error.message === 'string' ? error.message : ''; +const extractSanitizedServiceErrorMessage = (error: ConnectError): { sanitized: string; raw: string } => { + const rawDetails = typeof error.rawMessage === 'string' ? error.rawMessage.trim() : ''; + const rawMessage = typeof error.message === 'string' ? error.message.replace(/^\[[^\]]+\]\s*/, '').trim() : ''; const raw = rawDetails || rawMessage || ''; const sanitizedText = sanitizeInfraMessage(raw); const sanitized = sanitizedText.length > 0 ? sanitizedText : RUNNER_ERROR_MESSAGE_FALLBACK; @@ -150,10 +156,14 @@ type RunnerClientConfig = { address: string; sharedSecret: string; requestTimeoutMs?: number; + transport?: Transport; + sessionManager?: Http2SessionManager; }; +type RunnerServiceClient = Client; + export class RunnerGrpcClient implements DockerClient { - private readonly client: RunnerServiceGrpcClientInstance; + private readonly client: RunnerServiceClient; private readonly execClient: RunnerGrpcExecClient; private readonly sharedSecret: string; private readonly requestTimeoutMs: number; @@ -166,7 +176,15 @@ export class RunnerGrpcClient implements DockerClient { this.sharedSecret = config.sharedSecret; this.requestTimeoutMs = config.requestTimeoutMs ?? 30_000; this.endpoint = config.address; - this.client = new RunnerServiceGrpcClient(config.address, credentials.createInsecure()); + const baseUrl = normalizeBaseUrl(config.address); + const transport = + config.transport ?? + createGrpcTransport({ + baseUrl, + sessionManager: config.sessionManager, + interceptors: [this.createAuthInterceptor()], + }); + this.client = createClient(RunnerService, transport); this.execClient = new RunnerGrpcExecClient({ client: this.client, address: config.address, @@ -174,6 +192,7 @@ export class RunnerGrpcClient implements DockerClient { defaultDeadlineMs: this.requestTimeoutMs, resolveTimeout: (options) => this.resolveExecRequestTimeout(options), logger: this.logger, + transport, }); } @@ -184,12 +203,9 @@ export class RunnerGrpcClient implements DockerClient { async checkConnectivity(): Promise<{ status: string }> { const request = create(ReadyRequestSchema, {}); const response = await this.unary( - RUNNER_SERVICE_READY_PATH, + runnerServicePath('ready'), request, - (req, metadata, options, callback) => { - if (options) this.client.ready(req, metadata, options, callback); - else this.client.ready(req, metadata, callback); - }, + (req, options) => this.client.ready(req, options), ); return { status: response?.status ?? 'unknown' }; } @@ -197,15 +213,9 @@ export class RunnerGrpcClient implements DockerClient { async touchLastUsed(containerId: string): Promise { const request = create(TouchWorkloadRequestSchema, { workloadId: containerId }); await this.unary( - RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, + runnerServicePath('touchWorkload'), request, - (req, metadata, options, callback) => { - if (options) { - this.client.touchWorkload(req, metadata, options, (err: ServiceError | null) => callback(err)); - } else { - this.client.touchWorkload(req, metadata, (err: ServiceError | null) => callback(err)); - } - }, + (req, options) => this.client.touchWorkload(req, options), ); } @@ -216,12 +226,9 @@ export class RunnerGrpcClient implements DockerClient { async start(opts?: ContainerOpts): Promise { const request = containerOptsToStartWorkloadRequest(opts ?? {}) as StartWorkloadRequest; const response = await this.unary( - RUNNER_SERVICE_START_WORKLOAD_PATH, + runnerServicePath('startWorkload'), request, - (req, metadata, options, callback) => { - if (options) this.client.startWorkload(req, metadata, options, callback); - else this.client.startWorkload(req, metadata, callback); - }, + (req, options) => this.client.startWorkload(req, options), ); if (response.status === WorkloadStatus.FAILED) { const failure = response.failure; @@ -269,41 +276,54 @@ export class RunnerGrpcClient implements DockerClient { stderr: options?.stderr ?? true, timestamps: options?.timestamps ?? false, }); - const metadata = this.createMetadata(RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH); - const call = this.client.streamWorkloadLogs(request, metadata) as unknown as ClientReadableStream; const stream = new PassThrough(); + const abortController = new AbortController(); + const path = runnerServicePath('streamWorkloadLogs'); + const callOptions = this.buildCallOptions(undefined, abortController.signal, false); + const responses: AsyncIterable = this.client.streamWorkloadLogs(request, callOptions); - call.on('data', (response: StreamWorkloadLogsResponse) => { + const handleLogEvent = (response: StreamWorkloadLogsResponse) => { const event = response.event; if (!event?.case) return; - if (event.case === 'chunk') { - const chunk = Buffer.from(event.value.data ?? new Uint8Array()); - if (chunk.length > 0) stream.write(chunk); - return; + switch (event.case) { + case 'chunk': { + const chunk = Buffer.from(event.value.data ?? new Uint8Array()); + if (chunk.length > 0) stream.write(chunk); + return; + } + case 'end': { + stream.end(); + return; + } + case 'error': { + stream.emit('error', this.runnerErrorToException(event.value, 'runner_logs_error')); + return; + } } - if (event.case === 'end') { + }; + + const pump = async () => { + try { + for await (const response of responses) { + handleLogEvent(response); + } + } catch (error) { + if (!abortController.signal.aborted) { + stream.emit('error', this.translateServiceError(error, { path })); + } + } finally { stream.end(); - return; } - if (event.case === 'error') { - stream.emit('error', this.runnerErrorToException(event.value, 'runner_logs_error')); - } - }); - - call.on('error', (err: ServiceError) => { - stream.emit('error', this.translateServiceError(err, { path: RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH })); - }); + }; - call.on('end', () => { - stream.end(); - }); + void pump(); stream.on('close', () => { - call.cancel(); + abortController.abort(); }); const close = async (): Promise => { - call.cancel(); + abortController.abort(); }; return { stream, close }; @@ -316,15 +336,9 @@ export class RunnerGrpcClient implements DockerClient { async stopContainer(containerId: string, timeoutSec = 10): Promise { const request = create(StopWorkloadRequestSchema, { workloadId: containerId, timeoutSec }); await this.unary( - RUNNER_SERVICE_STOP_WORKLOAD_PATH, + runnerServicePath('stopWorkload'), request, - (req, metadata, options, callback) => { - if (options) { - this.client.stopWorkload(req, metadata, options, (err: ServiceError | null) => callback(err)); - } else { - this.client.stopWorkload(req, metadata, (err: ServiceError | null) => callback(err)); - } - }, + (req, options) => this.client.stopWorkload(req, options), ); } @@ -338,27 +352,18 @@ export class RunnerGrpcClient implements DockerClient { removeVolumes: typeof options === 'boolean' ? options : options?.removeVolumes ?? false, }); await this.unary( - RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, + runnerServicePath('removeWorkload'), request, - (req, metadata, callOptions, callback) => { - if (callOptions) { - this.client.removeWorkload(req, metadata, callOptions, (err: ServiceError | null) => callback(err)); - } else { - this.client.removeWorkload(req, metadata, (err: ServiceError | null) => callback(err)); - } - }, + (req, options) => this.client.removeWorkload(req, options), ); } async getContainerLabels(containerId: string): Promise | undefined> { const request = create(GetWorkloadLabelsRequestSchema, { workloadId: containerId }); const response = await this.unary( - RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH, + runnerServicePath('getWorkloadLabels'), request, - (req, metadata, options, callback) => { - if (options) this.client.getWorkloadLabels(req, metadata, options, callback); - else this.client.getWorkloadLabels(req, metadata, callback); - }, + (req, options) => this.client.getWorkloadLabels(req, options), ); return response?.labels; } @@ -379,12 +384,9 @@ export class RunnerGrpcClient implements DockerClient { all: options?.all ?? false, }); const response = await this.unary( - RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH, + runnerServicePath('findWorkloadsByLabels'), request, - (req, metadata, callOptions, callback) => { - if (callOptions) this.client.findWorkloadsByLabels(req, metadata, callOptions, callback); - else this.client.findWorkloadsByLabels(req, metadata, callback); - }, + (req, options) => this.client.findWorkloadsByLabels(req, options), ); const ids = response?.targetIds ?? []; return ids.map((id: string) => new ContainerHandle(this, id)); @@ -393,12 +395,9 @@ export class RunnerGrpcClient implements DockerClient { async listContainersByVolume(volumeName: string): Promise { const request = create(ListWorkloadsByVolumeRequestSchema, { volumeName }); const response = await this.unary( - RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH, + runnerServicePath('listWorkloadsByVolume'), request, - (req, metadata, options, callback) => { - if (options) this.client.listWorkloadsByVolume(req, metadata, options, callback); - else this.client.listWorkloadsByVolume(req, metadata, callback); - }, + (req, options) => this.client.listWorkloadsByVolume(req, options), ); return response?.targetIds ?? []; } @@ -409,15 +408,9 @@ export class RunnerGrpcClient implements DockerClient { force: options?.force ?? false, }); await this.unary( - RUNNER_SERVICE_REMOVE_VOLUME_PATH, + runnerServicePath('removeVolume'), request, - (req, metadata, callOptions, callback) => { - if (callOptions) { - this.client.removeVolume(req, metadata, callOptions, (err: ServiceError | null) => callback(err)); - } else { - this.client.removeVolume(req, metadata, (err: ServiceError | null) => callback(err)); - } - }, + (req, options) => this.client.removeVolume(req, options), ); } @@ -441,15 +434,9 @@ export class RunnerGrpcClient implements DockerClient { tarPayload: payload, }); await this.unary( - RUNNER_SERVICE_PUT_ARCHIVE_PATH, + runnerServicePath('putArchive'), request, - (req, metadata, callOptions, callback) => { - if (callOptions) { - this.client.putArchive(req, metadata, callOptions, (err: ServiceError | null) => callback(err)); - } else { - this.client.putArchive(req, metadata, (err: ServiceError | null) => callback(err)); - } - }, + (req, options) => this.client.putArchive(req, options), this.requestTimeoutMs, ); } @@ -457,12 +444,9 @@ export class RunnerGrpcClient implements DockerClient { async inspectContainer(containerId: string): Promise { const request = create(InspectWorkloadRequestSchema, { workloadId: containerId }); const response = await this.unary( - RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, + runnerServicePath('inspectWorkload'), request, - (req, metadata, options, callback) => { - if (options) this.client.inspectWorkload(req, metadata, options, callback); - else this.client.inspectWorkload(req, metadata, callback); - }, + (req, options) => this.client.inspectWorkload(req, options), ); return this.toInspectInfo(response); } @@ -472,33 +456,39 @@ export class RunnerGrpcClient implements DockerClient { since: this.normalizeSince(options?.since), filters: this.toEventFilters(options?.filters), }); - const metadata = this.createMetadata(RUNNER_SERVICE_STREAM_EVENTS_PATH); - const call = this.client.streamEvents(request, metadata) as unknown as ClientReadableStream; const stream = new PassThrough(); + const abortController = new AbortController(); + const path = runnerServicePath('streamEvents'); + const callOptions = this.buildCallOptions(undefined, abortController.signal, false); + const responses: AsyncIterable = this.client.streamEvents(request, callOptions); - call.on('data', (response: StreamEventsResponse) => { - const event = response.event; - if (!event?.case) return; - if (event.case === 'data') { - const json = event.value.json || '{}'; - stream.write(`${json}\n`); - return; - } - if (event.case === 'error') { - stream.emit('error', this.runnerErrorToException(event.value, 'runner_events_error')); + const pump = async () => { + try { + for await (const response of responses) { + const event = response.event; + if (!event?.case) continue; + if (event.case === 'data') { + const json = event.value.json || '{}'; + stream.write(`${json}\n`); + continue; + } + if (event.case === 'error') { + stream.emit('error', this.runnerErrorToException(event.value, 'runner_events_error')); + } + } + } catch (error) { + if (!abortController.signal.aborted) { + stream.emit('error', this.translateServiceError(error, { path })); + } + } finally { + stream.end(); } - }); + }; - call.on('error', (err: ServiceError) => { - stream.emit('error', this.translateServiceError(err, { path: RUNNER_SERVICE_STREAM_EVENTS_PATH })); - }); - - call.on('end', () => { - stream.end(); - }); + void pump(); stream.on('close', () => { - call.cancel(); + abortController.abort(); }); return stream; @@ -544,54 +534,41 @@ export class RunnerGrpcClient implements DockerClient { private unary( path: string, request: Request, - invoke: ( - request: Request, - metadata: Metadata, - options: CallOptions | undefined, - callback: (err: ServiceError | null, response?: Response) => void, - ) => void, + invoke: (request: Request, options?: CallOptions) => Promise, timeoutMs?: number, ): Promise { - const metadata = this.createMetadata(path); const callOptions = this.buildCallOptions(timeoutMs); - return new Promise((resolve, reject) => { - const callback = (err: ServiceError | null, response?: Response) => { - if (err) { - reject(this.translateServiceError(err, { path })); - return; - } - resolve(response as Response); - }; - invoke(request, metadata, callOptions, callback); + return invoke(request, callOptions).catch((error) => { + throw this.translateServiceError(error, { path }); }); } - private buildCallOptions(timeoutMs?: number): CallOptions | undefined { - const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : this.requestTimeoutMs; - if (!timeout || timeout <= 0) return undefined; - return { deadline: new Date(Date.now() + timeout) }; - } - - private createMetadata(path: string): Metadata { - const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret: this.sharedSecret }); - const metadata = new Metadata(); - for (const [key, value] of Object.entries(headers)) { - metadata.set(key, value); - } - return metadata; + private buildCallOptions( + timeoutMs?: number, + signal?: AbortSignal, + useDefaultTimeout: boolean = true, + ): CallOptions | undefined { + const fallbackTimeout = useDefaultTimeout ? this.requestTimeoutMs : undefined; + const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : fallbackTimeout; + if ((!timeout || timeout <= 0) && !signal) return undefined; + const options: CallOptions = {}; + if (timeout && timeout > 0) options.timeoutMs = timeout; + if (signal) options.signal = signal; + return options; } - private translateServiceError(error: ServiceError, context?: { path?: string }): DockerRunnerRequestError { - const grpcCode = typeof error.code === 'number' ? error.code : status.UNKNOWN; - const { sanitized: sanitizedMessage, raw: rawMessage } = extractSanitizedServiceErrorMessage(error); - if (grpcCode === status.CANCELLED) { + private translateServiceError(error: unknown, context?: { path?: string }): DockerRunnerRequestError { + const connectError = ConnectError.from(error); + const grpcCode = connectError.code ?? Code.Unknown; + const { sanitized: sanitizedMessage, raw: rawMessage } = extractSanitizedServiceErrorMessage(connectError); + if (grpcCode === Code.Canceled) { return new DockerRunnerRequestError(499, 'runner_exec_cancelled', false, sanitizedMessage); } const statusCode = this.grpcStatusToHttpStatus(grpcCode); const errorCode = this.grpcStatusToErrorCode(grpcCode); - const statusName = (status as unknown as Record)[grpcCode] ?? 'UNKNOWN'; + const statusName = Code[grpcCode] ?? 'UNKNOWN'; const path = context?.path ?? 'unknown'; - if (grpcCode === status.UNIMPLEMENTED) { + if (grpcCode === Code.Unimplemented) { this.logger.error(`Runner gRPC call returned UNIMPLEMENTED`, { path, grpcStatus: statusName, @@ -611,67 +588,65 @@ export class RunnerGrpcClient implements DockerClient { }); } const retryable = - grpcCode === status.UNAVAILABLE || - grpcCode === status.RESOURCE_EXHAUSTED || - grpcCode === status.DEADLINE_EXCEEDED; + grpcCode === Code.Unavailable || grpcCode === Code.ResourceExhausted || grpcCode === Code.DeadlineExceeded; return new DockerRunnerRequestError(statusCode, errorCode, retryable, sanitizedMessage); } - private grpcStatusToHttpStatus(grpcCode: status): number { + private grpcStatusToHttpStatus(grpcCode: Code): number { switch (grpcCode) { - case status.INVALID_ARGUMENT: + case Code.InvalidArgument: return 400; - case status.UNAUTHENTICATED: + case Code.Unauthenticated: return 401; - case status.PERMISSION_DENIED: + case Code.PermissionDenied: return 403; - case status.NOT_FOUND: + case Code.NotFound: return 404; - case status.ABORTED: + case Code.Aborted: return 409; - case status.FAILED_PRECONDITION: + case Code.FailedPrecondition: return 412; - case status.RESOURCE_EXHAUSTED: + case Code.ResourceExhausted: return 429; - case status.UNIMPLEMENTED: + case Code.Unimplemented: return 501; - case status.INTERNAL: - case status.DATA_LOSS: + case Code.Internal: + case Code.DataLoss: return 500; - case status.UNAVAILABLE: + case Code.Unavailable: return 503; - case status.DEADLINE_EXCEEDED: + case Code.DeadlineExceeded: return 504; default: return 502; } } - private grpcStatusToErrorCode(grpcCode: status): string { + private grpcStatusToErrorCode(grpcCode: Code): string { switch (grpcCode) { - case status.INVALID_ARGUMENT: + case Code.InvalidArgument: return 'runner_invalid_argument'; - case status.UNAUTHENTICATED: + case Code.Unauthenticated: return 'runner_unauthenticated'; - case status.PERMISSION_DENIED: + case Code.PermissionDenied: return 'runner_forbidden'; - case status.NOT_FOUND: + case Code.NotFound: return 'runner_not_found'; - case status.ABORTED: + case Code.Aborted: return 'runner_conflict'; - case status.FAILED_PRECONDITION: + case Code.FailedPrecondition: return 'runner_failed_precondition'; - case status.RESOURCE_EXHAUSTED: + case Code.ResourceExhausted: return 'runner_resource_exhausted'; - case status.UNIMPLEMENTED: + case Code.Unimplemented: return 'runner_unimplemented'; - case status.INTERNAL: + case Code.Internal: return 'runner_internal_error'; - case status.DATA_LOSS: + case Code.DataLoss: return 'runner_data_loss'; - case status.UNAVAILABLE: + case Code.Unavailable: return 'runner_unavailable'; - case status.DEADLINE_EXCEEDED: + case Code.DeadlineExceeded: return 'runner_timeout'; default: return 'runner_grpc_error'; @@ -690,6 +665,10 @@ export class RunnerGrpcClient implements DockerClient { ); } + private createAuthInterceptor(): Interceptor { + return createRunnerAuthInterceptor(this.sharedSecret); + } + private resolveExecRequestTimeout(options?: Pick): number | undefined { const candidates = [options?.timeoutMs, options?.idleTimeoutMs] .filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0); @@ -735,12 +714,58 @@ export class RunnerGrpcClient implements DockerClient { type ExecTimeoutResolver = (options?: Pick) => number | undefined; +type ExecRequestStreamHandle = { + push: (request: ExecRequest) => void; + end: () => void; + abort: () => void; +}; + +const createAsyncQueue = () => { + const queue: T[] = []; + let resolve: (() => void) | undefined; + let ended = false; + + const notify = () => { + if (resolve) { + resolve(); + resolve = undefined; + } + }; + + const push = (value: T) => { + if (ended) return; + queue.push(value); + notify(); + }; + + const end = () => { + if (ended) return; + ended = true; + notify(); + }; + + const iterate = async function* () { + while (true) { + if (queue.length > 0) { + yield queue.shift() as T; + continue; + } + if (ended) return; + await new Promise((resolveWait) => { + resolve = resolveWait; + }); + } + }; + + return { push, end, iterate }; +}; + export class RunnerGrpcExecClient { - private readonly client: RunnerServiceGrpcClientInstance; + private readonly client: RunnerServiceClient; private readonly sharedSecret: string; - private readonly defaultDeadlineMs?: number; + private readonly defaultTimeoutMs?: number; private readonly resolveTimeout?: ExecTimeoutResolver; - private readonly interactiveStreams = new Map>(); + private readonly interactiveStreams = new Map(); private readonly logger?: Logger; constructor(options: { @@ -748,96 +773,74 @@ export class RunnerGrpcExecClient { sharedSecret: string; defaultDeadlineMs?: number; resolveTimeout?: ExecTimeoutResolver; - client?: RunnerServiceGrpcClientInstance; + client?: RunnerServiceClient; + transport?: Transport; logger?: Logger; }) { - this.client = options.client ?? new RunnerServiceGrpcClient(options.address, credentials.createInsecure()); + const transport = + options.transport ?? + createGrpcTransport({ + baseUrl: normalizeBaseUrl(options.address), + interceptors: [createRunnerAuthInterceptor(options.sharedSecret)], + }); + this.client = options.client ?? createClient(RunnerService, transport); this.sharedSecret = options.sharedSecret; - this.defaultDeadlineMs = options.defaultDeadlineMs; + this.defaultTimeoutMs = options.defaultDeadlineMs; this.resolveTimeout = options.resolveTimeout; this.logger = options.logger; } async exec(containerId: string, command: string[] | string, options?: ExecOptions): Promise { - const metadata = this.createMetadata(RUNNER_SERVICE_EXEC_PATH); - const deadlineMs = this.resolveTimeout?.(options); - const callOptions: CallOptions | undefined = - typeof deadlineMs === 'number' && deadlineMs > 0 - ? { deadline: new Date(Date.now() + deadlineMs) } - : undefined; - const call = (callOptions ? this.client.exec(metadata, callOptions) : this.client.exec(metadata)) as ClientDuplexStream< - ExecRequest, - ExecResponse - >; + const requestStream = createAsyncQueue(); const execIdRef: { current?: string } = {}; - const endStream = () => { - try { - call.end(); - } catch { - // ignore end errors - } - }; const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; - let finished = false; - let requestedTimeoutMs: number | undefined; - let requestedIdleTimeoutMs: number | undefined; - const isAborted = this.attachAbortSignal(call, options?.signal, () => execIdRef.current); let stdinClosed = false; const sendStdinEof = () => { if (stdinClosed) return; stdinClosed = true; - try { - call.write( - create(ExecRequestSchema, { - msg: { case: 'stdin', value: create(ExecStdinSchema, { data: new Uint8Array(), eof: true }) }, - }), - ); - } catch { - // ignore eof errors - } + requestStream.push( + create(ExecRequestSchema, { + msg: { case: 'stdin', value: create(ExecStdinSchema, { data: new Uint8Array(), eof: true }) }, + }), + ); }; - return new Promise((resolve, reject) => { - const finalize = (result: ExecResult) => { - if (finished) return; - finished = true; - endStream(); - resolve(result); - }; + const start = this.createStartRequest({ containerId, command, execOptions: options }); + const { timeoutMs: requestedTimeoutMs, idleTimeoutMs: requestedIdleTimeoutMs } = this.extractRequestedTimeouts(start); + requestStream.push(start); - const fail = (error: Error) => { - if (finished) return; - finished = true; - endStream(); - reject(error); - }; - - call.on('data', (response: ExecResponse) => { - const event = response.event; - if (!event?.case) return; - if (event.case === 'started') { + const deadlineMs = this.resolveTimeout?.(options); + const callAbortController = new AbortController(); + const isAborted = this.attachAbortSignal(callAbortController, options?.signal, () => execIdRef.current); + const callOptions = this.buildCallOptions(deadlineMs, callAbortController.signal); + const responses: AsyncIterable = this.client.exec(requestStream.iterate(), callOptions); + + const handleExecResponse = (response: ExecResponse): ExecResult | undefined => { + const event = response.event; + if (!event?.case) return undefined; + switch (event.case) { + case 'started': execIdRef.current = event.value.executionId; sendStdinEof(); - return; - } - if (event.case === 'stdout') { + return undefined; + case 'stdout': { const chunk = Buffer.from(event.value.data ?? new Uint8Array()); if (chunk.length > 0) { stdoutChunks.push(chunk); options?.onOutput?.('stdout', chunk); } - return; + return undefined; } - if (event.case === 'stderr') { + case 'stderr': { const chunk = Buffer.from(event.value.data ?? new Uint8Array()); if (chunk.length > 0) { stderrChunks.push(chunk); options?.onOutput?.('stderr', chunk); } - return; + return undefined; } - if (event.case === 'exit') { + case 'exit': { const stdout = this.composeOutput(stdoutChunks, event.value.stdoutTail); const stderr = this.composeOutput(stderrChunks, event.value.stderrTail); const timeoutError = this.mapExitReasonToError(event.value.reason, { @@ -847,47 +850,43 @@ export class RunnerGrpcExecClient { idleTimeoutMs: requestedIdleTimeoutMs, }); if (timeoutError) { - fail(timeoutError); - return; + throw timeoutError; } - finalize({ exitCode: event.value.exitCode, stdout, stderr }); - return; + return { exitCode: event.value.exitCode, stdout, stderr }; } - if (event.case === 'error') { - fail(this.translateExecError(event.value)); - } - }); - - call.on('error', (err: ServiceError) => { - if (finished) return; - if (isAborted() && err.code === status.CANCELLED) { - fail(new DockerRunnerRequestError(499, 'runner_exec_cancelled', false, 'Execution aborted')); - return; - } - const timeoutError = this.mapGrpcDeadlineToTimeout(err, { - stdout: this.composeOutput(stdoutChunks), - stderr: this.composeOutput(stderrChunks), - timeoutMs: requestedTimeoutMs, - idleTimeoutMs: requestedIdleTimeoutMs, - }); - if (timeoutError) { - fail(timeoutError); - return; - } - fail(this.translateServiceError(err, { path: RUNNER_SERVICE_EXEC_PATH })); - }); + case 'error': + throw this.translateExecError(event.value); + } + return undefined; + }; - call.on('end', () => { - if (finished) return; - fail(new DockerRunnerRequestError(0, 'runner_stream_closed', true, 'Exec stream ended before exit event')); + try { + for await (const response of responses) { + const result = handleExecResponse(response); + if (result) return result; + } + throw new DockerRunnerRequestError(0, 'runner_stream_closed', true, 'Exec stream ended before exit event'); + } catch (error) { + if (error instanceof DockerRunnerRequestError || error instanceof ExecTimeoutError || error instanceof ExecIdleTimeoutError) { + throw error; + } + const connectError = ConnectError.from(error); + if (isAborted() && connectError.code === Code.Canceled) { + throw new DockerRunnerRequestError(499, 'runner_exec_cancelled', false, 'Execution aborted'); + } + const timeoutError = this.mapGrpcDeadlineToTimeout(connectError, { + stdout: this.composeOutput(stdoutChunks), + stderr: this.composeOutput(stderrChunks), + timeoutMs: requestedTimeoutMs, + idleTimeoutMs: requestedIdleTimeoutMs, }); - - const start = this.createStartRequest({ containerId, command, execOptions: options }); - const extractedTimeouts = this.extractRequestedTimeouts(start); - requestedTimeoutMs = extractedTimeouts.timeoutMs; - requestedIdleTimeoutMs = extractedTimeouts.idleTimeoutMs; - call.write(start); - }); + if (timeoutError) { + throw timeoutError; + } + throw this.translateServiceError(connectError, { path: runnerServicePath('exec') }); + } finally { + requestStream.end(); + } } async openInteractiveExec( @@ -895,8 +894,10 @@ export class RunnerGrpcExecClient { command: string[] | string, options?: InteractiveExecOptions, ): Promise { - const metadata = this.createMetadata(RUNNER_SERVICE_EXEC_PATH); - const call = this.client.exec(metadata) as ClientDuplexStream; + const requestStream = createAsyncQueue(); + const abortController = new AbortController(); + const callOptions = this.buildCallOptions(undefined, abortController.signal); + const responses: AsyncIterable = this.client.exec(requestStream.iterate(), callOptions); const stdout = new PassThrough(); const stderr = options?.demuxStderr === false ? undefined : new PassThrough(); stdout.on('error', () => undefined); @@ -913,6 +914,7 @@ export class RunnerGrpcExecClient { }); }); let execId: string | undefined; + const resolvedExecIdRef: { current?: string } = {}; let finished = false; let finalResult: ExecResult | undefined; let cancelledLocally = false; @@ -933,8 +935,15 @@ export class RunnerGrpcExecClient { closeReject = reject; }); + const streamHandle: ExecRequestStreamHandle = { + push: requestStream.push, + end: requestStream.end, + abort: () => abortController.abort(), + }; + const cleanupStream = () => { - if (execId) this.interactiveStreams.delete(execId); + const key = execId ?? resolvedExecIdRef.current; + if (key) this.interactiveStreams.delete(key); }; const finalize = (result: ExecResult) => { @@ -965,109 +974,126 @@ export class RunnerGrpcExecClient { closeReject?.(error); }; - call.on('data', (response: ExecResponse) => { - const event = response.event; - if (!event?.case) return; - if (event.case === 'started') { - execId = event.value.executionId; - if (execId) this.interactiveStreams.set(execId, call); - readyResolve?.(); - readyResolve = undefined; - readyReject = undefined; - startedSignaled = true; - if (stdout.listenerCount('data') > 0) { - stdout.emit('data', Buffer.alloc(0)); - } else { - syntheticReadyPending = true; - } - return; + const handleForcedTermination = (exitEvent: ExecExit, stdoutTail: string, stderrTail: string): boolean => { + if (!forcedTerminationReason) return false; + const shouldTranslate = + exitEvent.reason === ExecExitReason.CANCELLED || exitEvent.reason === ExecExitReason.COMPLETED; + if (!shouldTranslate) { + forcedTerminationReason = undefined; + return false; } - if (event.case === 'stdout') { - const chunk = Buffer.from(event.value.data ?? new Uint8Array()); - if (chunk.length > 0) stdout.write(chunk); - return; + const timeoutMs = + forcedTerminationReason === 'timeout' + ? this.resolveTimeoutValue(requestedTimeoutMs, requestedIdleTimeoutMs) + : this.resolveTimeoutValue(requestedIdleTimeoutMs, requestedTimeoutMs); + const forcedError = + forcedTerminationReason === 'timeout' + ? new ExecTimeoutError(timeoutMs, stdoutTail, stderrTail) + : new ExecIdleTimeoutError(timeoutMs, stdoutTail, stderrTail); + forcedTerminationReason = undefined; + fail(forcedError); + return true; + }; + + const handleExit = (exitEvent: ExecExit): boolean => { + const stdoutTail = Buffer.from(exitEvent.stdoutTail ?? new Uint8Array()).toString('utf8'); + const stderrTail = Buffer.from(exitEvent.stderrTail ?? new Uint8Array()).toString('utf8'); + if (handleForcedTermination(exitEvent, stdoutTail, stderrTail)) return true; + const timeoutError = this.mapExitReasonToError(exitEvent.reason, { + stdout: stdoutTail, + stderr: stderrTail, + timeoutMs: requestedTimeoutMs, + idleTimeoutMs: requestedIdleTimeoutMs, + }); + if (timeoutError) { + if (cancelledLocally && exitEvent.reason === ExecExitReason.IDLE_TIMEOUT) { + finalize({ exitCode: 0, stdout: stdoutTail, stderr: stderrTail }); + return true; + } + fail(timeoutError); + return true; } - if (event.case === 'stderr') { - const chunk = Buffer.from(event.value.data ?? new Uint8Array()); - if (!chunk.length) return; - if (stderr) stderr.write(chunk); - else stdout.write(chunk); - return; + finalize({ exitCode: exitEvent.exitCode, stdout: stdoutTail, stderr: stderrTail }); + return true; + }; + + const handleInteractiveResponse = (response: ExecResponse): boolean => { + const event = response.event; + if (!event?.case) return false; + switch (event.case) { + case 'started': { + execId = event.value.executionId; + if (execId) this.interactiveStreams.set(execId, streamHandle); + readyResolve?.(); + readyResolve = undefined; + readyReject = undefined; + startedSignaled = true; + if (stdout.listenerCount('data') > 0) { + stdout.emit('data', Buffer.alloc(0)); + } else { + syntheticReadyPending = true; + } + return false; + } + case 'stdout': { + const chunk = Buffer.from(event.value.data ?? new Uint8Array()); + if (chunk.length > 0) stdout.write(chunk); + return false; + } + case 'stderr': { + const chunk = Buffer.from(event.value.data ?? new Uint8Array()); + if (!chunk.length) return false; + if (stderr) stderr.write(chunk); + else stdout.write(chunk); + return false; + } + case 'exit': + return handleExit(event.value); + case 'error': + fail(this.translateExecError(event.value)); + return true; } - if (event.case === 'exit') { - const stdoutTail = Buffer.from(event.value.stdoutTail ?? new Uint8Array()).toString('utf8'); - const stderrTail = Buffer.from(event.value.stderrTail ?? new Uint8Array()).toString('utf8'); - if ( - forcedTerminationReason && - (event.value.reason === ExecExitReason.CANCELLED || event.value.reason === ExecExitReason.COMPLETED) - ) { - const timeoutMs = - forcedTerminationReason === 'timeout' - ? this.resolveTimeoutValue(requestedTimeoutMs, requestedIdleTimeoutMs) - : this.resolveTimeoutValue(requestedIdleTimeoutMs, requestedTimeoutMs); - const forcedError = - forcedTerminationReason === 'timeout' - ? new ExecTimeoutError(timeoutMs, stdoutTail, stderrTail) - : new ExecIdleTimeoutError(timeoutMs, stdoutTail, stderrTail); - forcedTerminationReason = undefined; - fail(forcedError); + return false; + }; + + const pump = async () => { + try { + for await (const response of responses) { + if (handleInteractiveResponse(response)) return; + } + if (!finished) { + fail(new DockerRunnerRequestError(0, 'runner_stream_closed', true, 'Exec stream ended before exit event')); + } + } catch (error) { + if (finished) return; + if (error instanceof DockerRunnerRequestError || error instanceof ExecTimeoutError || error instanceof ExecIdleTimeoutError) { + fail(error); return; } - forcedTerminationReason = undefined; - const timeoutError = this.mapExitReasonToError(event.value.reason, { - stdout: stdoutTail, - stderr: stderrTail, + const connectError = ConnectError.from(error); + const timeoutError = this.mapGrpcDeadlineToTimeout(connectError, { + stdout: finalResult?.stdout ?? '', + stderr: finalResult?.stderr ?? '', timeoutMs: requestedTimeoutMs, idleTimeoutMs: requestedIdleTimeoutMs, }); if (timeoutError) { - if (cancelledLocally && event.value.reason === ExecExitReason.IDLE_TIMEOUT) { - finalize({ exitCode: 0, stdout: stdoutTail, stderr: stderrTail }); - return; - } fail(timeoutError); return; } - finalize({ exitCode: event.value.exitCode, stdout: stdoutTail, stderr: stderrTail }); - return; - } - if (event.case === 'error') { - fail(this.translateExecError(event.value)); - } - }); - - call.on('error', (err: ServiceError) => { - if (finished) return; - const timeoutError = this.mapGrpcDeadlineToTimeout(err, { - stdout: finalResult?.stdout ?? '', - stderr: finalResult?.stderr ?? '', - timeoutMs: requestedTimeoutMs, - idleTimeoutMs: requestedIdleTimeoutMs, - }); - if (timeoutError) { - fail(timeoutError); - return; - } - const translated = this.translateServiceError(err, { path: RUNNER_SERVICE_EXEC_PATH }); - fail(translated); - }); - - call.on('end', () => { - if (finished) return; - cleanupStream(); - if (cancelledLocally) { - finalize({ exitCode: 0, stdout: '', stderr: '' }); - return; + const translated = this.translateServiceError(connectError, { path: runnerServicePath('exec') }); + fail(translated); + } finally { + requestStream.end(); } - fail(new DockerRunnerRequestError(0, 'runner_stream_closed', true, 'Exec stream ended before exit event')); - }); + }; const stdin = new Writable({ write: (chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) => { try { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding); if (buffer.length > 0) { - call.write( + requestStream.push( create(ExecRequestSchema, { msg: { case: 'stdin', value: create(ExecStdinSchema, { data: buffer, eof: false }) }, }), @@ -1081,12 +1107,12 @@ export class RunnerGrpcExecClient { }, final: (callback: (error?: Error | null) => void) => { try { - call.write( + requestStream.push( create(ExecRequestSchema, { msg: { case: 'stdin', value: create(ExecStdinSchema, { data: new Uint8Array(), eof: true }) }, }), ); - call.end(); + requestStream.end(); callback(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); @@ -1103,18 +1129,21 @@ export class RunnerGrpcExecClient { try { cancelledLocally = true; cleanupStream(); - call.cancel(); + abortController.abort(); } catch { // ignore cancellation errors } + requestStream.end(); return originalDestroy(error ?? undefined); }) as typeof stdin.destroy; - call.write(start); + requestStream.push(start); + void pump(); await readyPromise; const resolvedExecId = execId ?? randomUUID(); - if (!execId) this.interactiveStreams.set(resolvedExecId, call); + resolvedExecIdRef.current = resolvedExecId; + if (!execId) this.interactiveStreams.set(resolvedExecId, streamHandle); const close = async (): Promise => { if (finalResult) return finalResult; @@ -1131,16 +1160,8 @@ export class RunnerGrpcExecClient { } catch { // ignore } - try { - call.end(); - } catch { - // ignore - } - try { - call.cancel(); - } catch { - // ignore cancellation errors - } + requestStream.end(); + abortController.abort(); if (!finished) { const result: ExecResult = { exitCode: 0, stdout: '', stderr: '' }; finalize(result); @@ -1150,7 +1171,7 @@ export class RunnerGrpcExecClient { }; const terminateProcessGroup = async (reason: 'timeout' | 'idle_timeout'): Promise => { - const targetExecId = execId ?? resolvedExecId; + const targetExecId = execId ?? resolvedExecIdRef.current; if (!targetExecId) { throw new DockerRunnerRequestError(404, 'runner_exec_not_found', false, 'Execution not active'); } @@ -1176,18 +1197,23 @@ export class RunnerGrpcExecClient { } }; - return { stdin, stdout, stderr, close, execId: resolvedExecId, terminateProcessGroup }; + const inspect = async (): Promise => ({ + Running: !finished, + ExitCode: finalResult?.exitCode ?? null, + }); + + return { stdin, stdout, stderr, close, inspect, execId: resolvedExecId, terminateProcessGroup }; } async resizeExec(execId: string, size: { cols: number; rows: number }): Promise { - const call = this.interactiveStreams.get(execId); - if (!call) { + const stream = this.interactiveStreams.get(execId); + if (!stream) { throw new DockerRunnerRequestError(404, 'runner_exec_not_found', false, `Execution ${execId} not active`); } const cols = Math.max(0, Math.floor(size.cols)); const rows = Math.max(0, Math.floor(size.rows)); try { - call.write(create(ExecRequestSchema, { msg: { case: 'resize', value: create(ExecResizeSchema, { cols, rows }) } })); + stream.push(create(ExecRequestSchema, { msg: { case: 'resize', value: create(ExecResizeSchema, { cols, rows }) } })); } catch (error) { throw new DockerRunnerRequestError( 0, @@ -1199,36 +1225,24 @@ export class RunnerGrpcExecClient { } async cancelExecution(executionId: string, force = false): Promise { - const metadata = this.createMetadata(RUNNER_SERVICE_CANCEL_EXEC_PATH); - const deadlineMs = this.defaultDeadlineMs; - const callOptions: CallOptions | undefined = - typeof deadlineMs === 'number' && deadlineMs > 0 - ? { deadline: new Date(Date.now() + deadlineMs) } - : undefined; + const deadlineMs = this.defaultTimeoutMs; + const callOptions = this.buildCallOptions(deadlineMs); const request = create(CancelExecutionRequestSchema, { executionId, force }); - return new Promise((resolve, reject) => { - const callback = (err: ServiceError | null, response?: CancelExecutionResponse) => { - if (err) { - reject(this.translateServiceError(err, { path: RUNNER_SERVICE_CANCEL_EXEC_PATH })); - return; - } - resolve(response?.cancelled ?? false); - }; - if (callOptions) { - this.client.cancelExecution(request, metadata, callOptions, callback); - } else { - this.client.cancelExecution(request, metadata, callback); - } - }); + try { + const response = await this.client.cancelExecution(request, callOptions); + return response?.cancelled ?? false; + } catch (error) { + throw this.translateServiceError(error, { path: runnerServicePath('cancelExecution') }); + } } - private createMetadata(path: string): Metadata { - const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret: this.sharedSecret }); - const metadata = new Metadata(); - for (const [key, value] of Object.entries(headers)) { - metadata.set(key, value); - } - return metadata; + private buildCallOptions(timeoutMs?: number, signal?: AbortSignal): CallOptions | undefined { + const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined; + if (!timeout && !signal) return undefined; + const options: CallOptions = {}; + if (timeout) options.timeoutMs = timeout; + if (signal) options.signal = signal; + return options; } private createStartRequest(params: { @@ -1334,12 +1348,12 @@ export class RunnerGrpcExecClient { } private mapGrpcDeadlineToTimeout( - error: ServiceError, + error: ConnectError, context: { stdout: string; stderr: string; timeoutMs?: number; idleTimeoutMs?: number }, ): ExecTimeoutError | undefined { - if (error.code !== status.DEADLINE_EXCEEDED) return undefined; + if (error.code !== Code.DeadlineExceeded) return undefined; const resolved = this.resolveTimeoutValue(context.timeoutMs, context.idleTimeoutMs); - const effectiveTimeout = resolved > 0 ? resolved : this.defaultDeadlineMs ?? 0; + const effectiveTimeout = resolved > 0 ? resolved : this.defaultTimeoutMs ?? 0; return new ExecTimeoutError(effectiveTimeout, context.stdout, context.stderr); } @@ -1349,15 +1363,16 @@ export class RunnerGrpcExecClient { return 0; } - private translateServiceError(error: ServiceError, context?: { path?: string }): DockerRunnerRequestError { - const grpcCode = typeof error.code === 'number' ? error.code : status.UNKNOWN; - const { sanitized: sanitizedMessage, raw: rawMessage } = extractSanitizedServiceErrorMessage(error); - if (grpcCode === status.CANCELLED) { + private translateServiceError(error: unknown, context?: { path?: string }): DockerRunnerRequestError { + const connectError = ConnectError.from(error); + const grpcCode = connectError.code ?? Code.Unknown; + const { sanitized: sanitizedMessage, raw: rawMessage } = extractSanitizedServiceErrorMessage(connectError); + if (grpcCode === Code.Canceled) { return new DockerRunnerRequestError(499, 'runner_exec_cancelled', false, sanitizedMessage); } - const statusName = (status as unknown as Record)[grpcCode] ?? 'UNKNOWN'; - const path = context?.path ?? RUNNER_SERVICE_EXEC_PATH; - if (grpcCode === status.UNIMPLEMENTED) { + const statusName = Code[grpcCode] ?? 'UNKNOWN'; + const path = context?.path ?? runnerServicePath('exec'); + if (grpcCode === Code.Unimplemented) { this.logger?.error(`Runner exec gRPC call returned UNIMPLEMENTED`, { path, grpcStatus: statusName, @@ -1375,28 +1390,22 @@ export class RunnerGrpcExecClient { }); } const retryable = - grpcCode === status.UNAVAILABLE || - grpcCode === status.RESOURCE_EXHAUSTED || - grpcCode === status.DEADLINE_EXCEEDED; + grpcCode === Code.Unavailable || grpcCode === Code.ResourceExhausted || grpcCode === Code.DeadlineExceeded; return new DockerRunnerRequestError(0, 'runner_grpc_error', retryable, sanitizedMessage); } private attachAbortSignal( - call: ClientDuplexStream, + controller: AbortController, signal: AbortSignal | undefined, resolveExecId: () => string | undefined, ): () => boolean { - if (!signal) return () => false; + if (!signal) return () => controller.signal.aborted; const abortHandler = () => { const execId = resolveExecId(); if (execId) { void this.cancelExecution(execId).catch(() => undefined); } - try { - call.cancel(); - } catch { - // ignore cancellation errors - } + controller.abort(); }; if (signal.aborted) { abortHandler(); diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts deleted file mode 100644 index cd433e4cf..000000000 --- a/packages/platform-server/src/proto/grpc.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { makeGenericClientConstructor } from '@grpc/grpc-js'; -import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; -import { toBinary, fromBinary } from '@bufbuild/protobuf'; -import type { DescMessage } from '@bufbuild/protobuf'; -import { - CancelExecutionRequestSchema, - CancelExecutionResponseSchema, - ExecRequestSchema, - ExecResponseSchema, - FindWorkloadsByLabelsRequestSchema, - FindWorkloadsByLabelsResponseSchema, - GetWorkloadLabelsRequestSchema, - GetWorkloadLabelsResponseSchema, - InspectWorkloadRequestSchema, - InspectWorkloadResponseSchema, - ListWorkloadsByVolumeRequestSchema, - ListWorkloadsByVolumeResponseSchema, - PutArchiveRequestSchema, - PutArchiveResponseSchema, - ReadyRequestSchema, - ReadyResponseSchema, - RemoveVolumeRequestSchema, - RemoveVolumeResponseSchema, - RemoveWorkloadRequestSchema, - RemoveWorkloadResponseSchema, - StartWorkloadRequestSchema, - StartWorkloadResponseSchema, - StopWorkloadRequestSchema, - StopWorkloadResponseSchema, - StreamEventsRequestSchema, - StreamEventsResponseSchema, - StreamWorkloadLogsRequestSchema, - StreamWorkloadLogsResponseSchema, - TouchWorkloadRequestSchema, - TouchWorkloadResponseSchema, -} from './gen/agynio/api/runner/v1/runner_pb.js'; - -const unaryDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: false, - responseStream: false, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -const serverStreamDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: false, - responseStream: true, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -const bidiDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: true, - responseStream: true, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -export const RUNNER_SERVICE_READY_PATH = '/agynio.api.runner.v1.RunnerService/Ready'; -export const RUNNER_SERVICE_START_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StartWorkload'; -export const RUNNER_SERVICE_STOP_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StopWorkload'; -export const RUNNER_SERVICE_REMOVE_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/RemoveWorkload'; -export const RUNNER_SERVICE_INSPECT_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/InspectWorkload'; -export const RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/GetWorkloadLabels'; -export const RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH = - '/agynio.api.runner.v1.RunnerService/FindWorkloadsByLabels'; -export const RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH = - '/agynio.api.runner.v1.RunnerService/ListWorkloadsByVolume'; -export const RUNNER_SERVICE_REMOVE_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/RemoveVolume'; -export const RUNNER_SERVICE_TOUCH_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/TouchWorkload'; -export const RUNNER_SERVICE_PUT_ARCHIVE_PATH = '/agynio.api.runner.v1.RunnerService/PutArchive'; -export const RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH = '/agynio.api.runner.v1.RunnerService/StreamWorkloadLogs'; -export const RUNNER_SERVICE_STREAM_EVENTS_PATH = '/agynio.api.runner.v1.RunnerService/StreamEvents'; -export const RUNNER_SERVICE_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/Exec'; -export const RUNNER_SERVICE_CANCEL_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/CancelExecution'; - -export const runnerServiceGrpcDefinition: ServiceDefinition = { - ready: unaryDefinition( - RUNNER_SERVICE_READY_PATH, - ReadyRequestSchema, - ReadyResponseSchema, - ), - startWorkload: unaryDefinition( - RUNNER_SERVICE_START_WORKLOAD_PATH, - StartWorkloadRequestSchema, - StartWorkloadResponseSchema, - ), - stopWorkload: unaryDefinition( - RUNNER_SERVICE_STOP_WORKLOAD_PATH, - StopWorkloadRequestSchema, - StopWorkloadResponseSchema, - ), - removeWorkload: unaryDefinition( - RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, - RemoveWorkloadRequestSchema, - RemoveWorkloadResponseSchema, - ), - inspectWorkload: unaryDefinition( - RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, - InspectWorkloadRequestSchema, - InspectWorkloadResponseSchema, - ), - getWorkloadLabels: unaryDefinition( - RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH, - GetWorkloadLabelsRequestSchema, - GetWorkloadLabelsResponseSchema, - ), - findWorkloadsByLabels: unaryDefinition( - RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH, - FindWorkloadsByLabelsRequestSchema, - FindWorkloadsByLabelsResponseSchema, - ), - listWorkloadsByVolume: unaryDefinition( - RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH, - ListWorkloadsByVolumeRequestSchema, - ListWorkloadsByVolumeResponseSchema, - ), - removeVolume: unaryDefinition( - RUNNER_SERVICE_REMOVE_VOLUME_PATH, - RemoveVolumeRequestSchema, - RemoveVolumeResponseSchema, - ), - touchWorkload: unaryDefinition( - RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, - TouchWorkloadRequestSchema, - TouchWorkloadResponseSchema, - ), - putArchive: unaryDefinition( - RUNNER_SERVICE_PUT_ARCHIVE_PATH, - PutArchiveRequestSchema, - PutArchiveResponseSchema, - ), - streamWorkloadLogs: serverStreamDefinition( - RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH, - StreamWorkloadLogsRequestSchema, - StreamWorkloadLogsResponseSchema, - ), - streamEvents: serverStreamDefinition( - RUNNER_SERVICE_STREAM_EVENTS_PATH, - StreamEventsRequestSchema, - StreamEventsResponseSchema, - ), - exec: bidiDefinition( - RUNNER_SERVICE_EXEC_PATH, - ExecRequestSchema, - ExecResponseSchema, - ), - cancelExecution: unaryDefinition( - RUNNER_SERVICE_CANCEL_EXEC_PATH, - CancelExecutionRequestSchema, - CancelExecutionResponseSchema, - ), -}; - -export const RunnerServiceGrpcClient = makeGenericClientConstructor( - runnerServiceGrpcDefinition, - 'agynio.api.runner.v1.RunnerService', -); - -export type RunnerServiceGrpcClientInstance = InstanceType; diff --git a/packages/platform-server/src/proto/teams-grpc.ts b/packages/platform-server/src/proto/teams-grpc.ts deleted file mode 100644 index 3c67625bb..000000000 --- a/packages/platform-server/src/proto/teams-grpc.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { makeGenericClientConstructor } from '@grpc/grpc-js'; -import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; -import { toBinary, fromBinary } from '@bufbuild/protobuf'; -import type { DescMessage } from '@bufbuild/protobuf'; -import { - CreateAgentRequestSchema, - CreateAgentResponseSchema, - CreateAttachmentRequestSchema, - CreateAttachmentResponseSchema, - CreateMcpServerRequestSchema, - CreateMcpServerResponseSchema, - CreateMemoryBucketRequestSchema, - CreateMemoryBucketResponseSchema, - CreateToolRequestSchema, - CreateToolResponseSchema, - CreateWorkspaceConfigurationRequestSchema, - CreateWorkspaceConfigurationResponseSchema, - DeleteAgentRequestSchema, - DeleteAgentResponseSchema, - DeleteAttachmentRequestSchema, - DeleteAttachmentResponseSchema, - DeleteMcpServerRequestSchema, - DeleteMcpServerResponseSchema, - DeleteMemoryBucketRequestSchema, - DeleteMemoryBucketResponseSchema, - DeleteToolRequestSchema, - DeleteToolResponseSchema, - DeleteWorkspaceConfigurationRequestSchema, - DeleteWorkspaceConfigurationResponseSchema, - GetAgentRequestSchema, - GetAgentResponseSchema, - GetMcpServerRequestSchema, - GetMcpServerResponseSchema, - GetMemoryBucketRequestSchema, - GetMemoryBucketResponseSchema, - GetToolRequestSchema, - GetToolResponseSchema, - GetWorkspaceConfigurationRequestSchema, - GetWorkspaceConfigurationResponseSchema, - ListAgentsRequestSchema, - ListAgentsResponseSchema, - ListAttachmentsRequestSchema, - ListAttachmentsResponseSchema, - ListMcpServersRequestSchema, - ListMcpServersResponseSchema, - ListMemoryBucketsRequestSchema, - ListMemoryBucketsResponseSchema, - ListToolsRequestSchema, - ListToolsResponseSchema, - ListWorkspaceConfigurationsRequestSchema, - ListWorkspaceConfigurationsResponseSchema, - UpdateAgentRequestSchema, - UpdateAgentResponseSchema, - UpdateMcpServerRequestSchema, - UpdateMcpServerResponseSchema, - UpdateMemoryBucketRequestSchema, - UpdateMemoryBucketResponseSchema, - UpdateToolRequestSchema, - UpdateToolResponseSchema, - UpdateWorkspaceConfigurationRequestSchema, - UpdateWorkspaceConfigurationResponseSchema, -} from './gen/agynio/api/teams/v1/teams_pb.js'; - -const unaryDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: false, - responseStream: false, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; -export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; -export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; -export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; -export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; -export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; -export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; -export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; -export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; -export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; -export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; -export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; -export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; -export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; -export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; -export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = - '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; -export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; -export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; -export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; -export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; -export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; -export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; -export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; -export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; -export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; -export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; -export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; -export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; - -export const teamsServiceGrpcDefinition: ServiceDefinition = { - listAgents: unaryDefinition( - TEAMS_SERVICE_LIST_AGENTS_PATH, - ListAgentsRequestSchema, - ListAgentsResponseSchema, - ), - createAgent: unaryDefinition( - TEAMS_SERVICE_CREATE_AGENT_PATH, - CreateAgentRequestSchema, - CreateAgentResponseSchema, - ), - getAgent: unaryDefinition( - TEAMS_SERVICE_GET_AGENT_PATH, - GetAgentRequestSchema, - GetAgentResponseSchema, - ), - updateAgent: unaryDefinition( - TEAMS_SERVICE_UPDATE_AGENT_PATH, - UpdateAgentRequestSchema, - UpdateAgentResponseSchema, - ), - deleteAgent: unaryDefinition( - TEAMS_SERVICE_DELETE_AGENT_PATH, - DeleteAgentRequestSchema, - DeleteAgentResponseSchema, - ), - listTools: unaryDefinition( - TEAMS_SERVICE_LIST_TOOLS_PATH, - ListToolsRequestSchema, - ListToolsResponseSchema, - ), - createTool: unaryDefinition( - TEAMS_SERVICE_CREATE_TOOL_PATH, - CreateToolRequestSchema, - CreateToolResponseSchema, - ), - getTool: unaryDefinition( - TEAMS_SERVICE_GET_TOOL_PATH, - GetToolRequestSchema, - GetToolResponseSchema, - ), - updateTool: unaryDefinition( - TEAMS_SERVICE_UPDATE_TOOL_PATH, - UpdateToolRequestSchema, - UpdateToolResponseSchema, - ), - deleteTool: unaryDefinition( - TEAMS_SERVICE_DELETE_TOOL_PATH, - DeleteToolRequestSchema, - DeleteToolResponseSchema, - ), - listMcpServers: unaryDefinition( - TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, - ListMcpServersRequestSchema, - ListMcpServersResponseSchema, - ), - createMcpServer: unaryDefinition( - TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - CreateMcpServerRequestSchema, - CreateMcpServerResponseSchema, - ), - getMcpServer: unaryDefinition( - TEAMS_SERVICE_GET_MCP_SERVER_PATH, - GetMcpServerRequestSchema, - GetMcpServerResponseSchema, - ), - updateMcpServer: unaryDefinition( - TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - UpdateMcpServerRequestSchema, - UpdateMcpServerResponseSchema, - ), - deleteMcpServer: unaryDefinition( - TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, - DeleteMcpServerRequestSchema, - DeleteMcpServerResponseSchema, - ), - listWorkspaceConfigurations: unaryDefinition( - TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, - ListWorkspaceConfigurationsRequestSchema, - ListWorkspaceConfigurationsResponseSchema, - ), - createWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - CreateWorkspaceConfigurationRequestSchema, - CreateWorkspaceConfigurationResponseSchema, - ), - getWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, - GetWorkspaceConfigurationRequestSchema, - GetWorkspaceConfigurationResponseSchema, - ), - updateWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, - UpdateWorkspaceConfigurationRequestSchema, - UpdateWorkspaceConfigurationResponseSchema, - ), - deleteWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, - DeleteWorkspaceConfigurationRequestSchema, - DeleteWorkspaceConfigurationResponseSchema, - ), - listMemoryBuckets: unaryDefinition( - TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, - ListMemoryBucketsRequestSchema, - ListMemoryBucketsResponseSchema, - ), - createMemoryBucket: unaryDefinition( - TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - CreateMemoryBucketRequestSchema, - CreateMemoryBucketResponseSchema, - ), - getMemoryBucket: unaryDefinition( - TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, - GetMemoryBucketRequestSchema, - GetMemoryBucketResponseSchema, - ), - updateMemoryBucket: unaryDefinition( - TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - UpdateMemoryBucketRequestSchema, - UpdateMemoryBucketResponseSchema, - ), - deleteMemoryBucket: unaryDefinition( - TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, - DeleteMemoryBucketRequestSchema, - DeleteMemoryBucketResponseSchema, - ), - listAttachments: unaryDefinition( - TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, - ListAttachmentsRequestSchema, - ListAttachmentsResponseSchema, - ), - createAttachment: unaryDefinition( - TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - CreateAttachmentRequestSchema, - CreateAttachmentResponseSchema, - ), - deleteAttachment: unaryDefinition( - TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, - DeleteAttachmentRequestSchema, - DeleteAttachmentResponseSchema, - ), -}; - -export const TeamsServiceGrpcClient = makeGenericClientConstructor( - teamsServiceGrpcDefinition, - 'agynio.api.teams.v1.TeamsService', -); - -export type TeamsServiceGrpcClientInstance = InstanceType; diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts index 47e197364..81f84a449 100644 --- a/packages/platform-server/src/teams/teamsGrpc.client.ts +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -1,6 +1,7 @@ import { create, type DescMessage } from '@bufbuild/protobuf'; import { HttpException, HttpStatus, Logger } from '@nestjs/common'; -import { credentials, Metadata, status, type CallOptions, type ServiceError } from '@grpc/grpc-js'; +import { Code, ConnectError, createClient, type CallOptions, type Client } from '@connectrpc/connect'; +import { createGrpcTransport } from '@connectrpc/connect-node'; import { CreateAgentRequestSchema, CreateAttachmentRequestSchema, @@ -25,6 +26,7 @@ import { ListMemoryBucketsRequestSchema, ListToolsRequestSchema, ListWorkspaceConfigurationsRequestSchema, + TeamsService, UpdateAgentRequestSchema, UpdateMcpServerRequestSchema, UpdateMemoryBucketRequestSchema, @@ -89,61 +91,33 @@ import type { UpdateWorkspaceConfigurationRequest, UpdateWorkspaceConfigurationResponse, } from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; -import { - TeamsServiceGrpcClient, - type TeamsServiceGrpcClientInstance, - TEAMS_SERVICE_CREATE_AGENT_PATH, - TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - TEAMS_SERVICE_CREATE_TOOL_PATH, - TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - TEAMS_SERVICE_DELETE_AGENT_PATH, - TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, - TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, - TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, - TEAMS_SERVICE_DELETE_TOOL_PATH, - TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, - TEAMS_SERVICE_GET_AGENT_PATH, - TEAMS_SERVICE_GET_MCP_SERVER_PATH, - TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, - TEAMS_SERVICE_GET_TOOL_PATH, - TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, - TEAMS_SERVICE_LIST_AGENTS_PATH, - TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, - TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, - TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, - TEAMS_SERVICE_LIST_TOOLS_PATH, - TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, - TEAMS_SERVICE_UPDATE_AGENT_PATH, - TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - TEAMS_SERVICE_UPDATE_TOOL_PATH, - TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, -} from '../proto/teams-grpc.js'; type TeamsGrpcClientConfig = { address: string; requestTimeoutMs?: number; }; -type UnaryRpcCall = { - (request: Req, metadata: Metadata, callback: (err: ServiceError | null, response?: Res) => void): void; - ( - request: Req, - metadata: Metadata, - options: CallOptions, - callback: (err: ServiceError | null, response?: Res) => void, - ): void; -}; +type TeamsServiceClient = Client; +type UnaryRpcCall = (request: Req, options?: CallOptions) => Promise; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_ERROR_MESSAGE = 'Teams service request failed'; +const normalizeBaseUrl = (address: string): string => { + const trimmed = address.trim(); + if (!trimmed) return ''; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^grpc:\/\//i.test(trimmed)) return `http://${trimmed.slice('grpc://'.length)}`; + return `http://${trimmed}`; +}; + +const teamsServicePath = (method: keyof typeof TeamsService.method): string => + `/${TeamsService.typeName}/${TeamsService.method[method].name}`; + export class TeamsGrpcRequestError extends HttpException { constructor( statusCode: number, - readonly grpcCode: status, + readonly grpcCode: Code, readonly errorCode: string, message: string, ) { @@ -153,7 +127,7 @@ export class TeamsGrpcRequestError extends HttpException { } export class TeamsGrpcClient { - private readonly client: TeamsServiceGrpcClientInstance; + private readonly client: TeamsServiceClient; private readonly requestTimeoutMs: number; private readonly endpoint: string; private readonly logger = new Logger(TeamsGrpcClient.name); @@ -165,7 +139,8 @@ export class TeamsGrpcClient { } this.endpoint = address; this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - this.client = new TeamsServiceGrpcClient(address, credentials.createInsecure()); + const baseUrl = normalizeBaseUrl(address); + this.client = createClient(TeamsService, createGrpcTransport({ baseUrl })); } getEndpoint(): string { @@ -174,7 +149,7 @@ export class TeamsGrpcClient { async listAgents(request: ListAgentsRequest): Promise { return this.call( - TEAMS_SERVICE_LIST_AGENTS_PATH, + teamsServicePath('listAgents'), ListAgentsRequestSchema, request, 'listAgents', @@ -183,7 +158,7 @@ export class TeamsGrpcClient { async createAgent(request: CreateAgentRequest): Promise { return this.call( - TEAMS_SERVICE_CREATE_AGENT_PATH, + teamsServicePath('createAgent'), CreateAgentRequestSchema, request, 'createAgent', @@ -192,7 +167,7 @@ export class TeamsGrpcClient { async getAgent(request: GetAgentRequest): Promise { return this.call( - TEAMS_SERVICE_GET_AGENT_PATH, + teamsServicePath('getAgent'), GetAgentRequestSchema, request, 'getAgent', @@ -201,7 +176,7 @@ export class TeamsGrpcClient { async updateAgent(request: UpdateAgentRequest): Promise { return this.call( - TEAMS_SERVICE_UPDATE_AGENT_PATH, + teamsServicePath('updateAgent'), UpdateAgentRequestSchema, request, 'updateAgent', @@ -210,7 +185,7 @@ export class TeamsGrpcClient { async deleteAgent(request: DeleteAgentRequest): Promise { await this.call( - TEAMS_SERVICE_DELETE_AGENT_PATH, + teamsServicePath('deleteAgent'), DeleteAgentRequestSchema, request, 'deleteAgent', @@ -219,7 +194,7 @@ export class TeamsGrpcClient { async listTools(request: ListToolsRequest): Promise { return this.call( - TEAMS_SERVICE_LIST_TOOLS_PATH, + teamsServicePath('listTools'), ListToolsRequestSchema, request, 'listTools', @@ -228,7 +203,7 @@ export class TeamsGrpcClient { async createTool(request: CreateToolRequest): Promise { return this.call( - TEAMS_SERVICE_CREATE_TOOL_PATH, + teamsServicePath('createTool'), CreateToolRequestSchema, request, 'createTool', @@ -237,7 +212,7 @@ export class TeamsGrpcClient { async getTool(request: GetToolRequest): Promise { return this.call( - TEAMS_SERVICE_GET_TOOL_PATH, + teamsServicePath('getTool'), GetToolRequestSchema, request, 'getTool', @@ -246,7 +221,7 @@ export class TeamsGrpcClient { async updateTool(request: UpdateToolRequest): Promise { return this.call( - TEAMS_SERVICE_UPDATE_TOOL_PATH, + teamsServicePath('updateTool'), UpdateToolRequestSchema, request, 'updateTool', @@ -255,7 +230,7 @@ export class TeamsGrpcClient { async deleteTool(request: DeleteToolRequest): Promise { await this.call( - TEAMS_SERVICE_DELETE_TOOL_PATH, + teamsServicePath('deleteTool'), DeleteToolRequestSchema, request, 'deleteTool', @@ -264,7 +239,7 @@ export class TeamsGrpcClient { async listMcpServers(request: ListMcpServersRequest): Promise { return this.call( - TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + teamsServicePath('listMcpServers'), ListMcpServersRequestSchema, request, 'listMcpServers', @@ -273,7 +248,7 @@ export class TeamsGrpcClient { async createMcpServer(request: CreateMcpServerRequest): Promise { return this.call( - TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + teamsServicePath('createMcpServer'), CreateMcpServerRequestSchema, request, 'createMcpServer', @@ -282,7 +257,7 @@ export class TeamsGrpcClient { async getMcpServer(request: GetMcpServerRequest): Promise { return this.call( - TEAMS_SERVICE_GET_MCP_SERVER_PATH, + teamsServicePath('getMcpServer'), GetMcpServerRequestSchema, request, 'getMcpServer', @@ -291,7 +266,7 @@ export class TeamsGrpcClient { async updateMcpServer(request: UpdateMcpServerRequest): Promise { return this.call( - TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + teamsServicePath('updateMcpServer'), UpdateMcpServerRequestSchema, request, 'updateMcpServer', @@ -300,7 +275,7 @@ export class TeamsGrpcClient { async deleteMcpServer(request: DeleteMcpServerRequest): Promise { await this.call( - TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + teamsServicePath('deleteMcpServer'), DeleteMcpServerRequestSchema, request, 'deleteMcpServer', @@ -311,7 +286,7 @@ export class TeamsGrpcClient { request: ListWorkspaceConfigurationsRequest, ): Promise { return this.call( - TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + teamsServicePath('listWorkspaceConfigurations'), ListWorkspaceConfigurationsRequestSchema, request, 'listWorkspaceConfigurations', @@ -322,7 +297,7 @@ export class TeamsGrpcClient { request: CreateWorkspaceConfigurationRequest, ): Promise { return this.call( - TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + teamsServicePath('createWorkspaceConfiguration'), CreateWorkspaceConfigurationRequestSchema, request, 'createWorkspaceConfiguration', @@ -333,7 +308,7 @@ export class TeamsGrpcClient { request: GetWorkspaceConfigurationRequest, ): Promise { return this.call( - TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + teamsServicePath('getWorkspaceConfiguration'), GetWorkspaceConfigurationRequestSchema, request, 'getWorkspaceConfiguration', @@ -344,7 +319,7 @@ export class TeamsGrpcClient { request: UpdateWorkspaceConfigurationRequest, ): Promise { return this.call( - TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, + teamsServicePath('updateWorkspaceConfiguration'), UpdateWorkspaceConfigurationRequestSchema, request, 'updateWorkspaceConfiguration', @@ -353,7 +328,7 @@ export class TeamsGrpcClient { async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { await this.call( - TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + teamsServicePath('deleteWorkspaceConfiguration'), DeleteWorkspaceConfigurationRequestSchema, request, 'deleteWorkspaceConfiguration', @@ -362,7 +337,7 @@ export class TeamsGrpcClient { async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { return this.call( - TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + teamsServicePath('listMemoryBuckets'), ListMemoryBucketsRequestSchema, request, 'listMemoryBuckets', @@ -371,7 +346,7 @@ export class TeamsGrpcClient { async createMemoryBucket(request: CreateMemoryBucketRequest): Promise { return this.call( - TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + teamsServicePath('createMemoryBucket'), CreateMemoryBucketRequestSchema, request, 'createMemoryBucket', @@ -380,7 +355,7 @@ export class TeamsGrpcClient { async getMemoryBucket(request: GetMemoryBucketRequest): Promise { return this.call( - TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + teamsServicePath('getMemoryBucket'), GetMemoryBucketRequestSchema, request, 'getMemoryBucket', @@ -389,7 +364,7 @@ export class TeamsGrpcClient { async updateMemoryBucket(request: UpdateMemoryBucketRequest): Promise { return this.call( - TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + teamsServicePath('updateMemoryBucket'), UpdateMemoryBucketRequestSchema, request, 'updateMemoryBucket', @@ -398,7 +373,7 @@ export class TeamsGrpcClient { async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { await this.call( - TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + teamsServicePath('deleteMemoryBucket'), DeleteMemoryBucketRequestSchema, request, 'deleteMemoryBucket', @@ -407,7 +382,7 @@ export class TeamsGrpcClient { async listAttachments(request: ListAttachmentsRequest): Promise { return this.call( - TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + teamsServicePath('listAttachments'), ListAttachmentsRequestSchema, request, 'listAttachments', @@ -416,7 +391,7 @@ export class TeamsGrpcClient { async createAttachment(request: CreateAttachmentRequest): Promise { return this.call( - TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + teamsServicePath('createAttachment'), CreateAttachmentRequestSchema, request, 'createAttachment', @@ -425,7 +400,7 @@ export class TeamsGrpcClient { async deleteAttachment(request: DeleteAttachmentRequest): Promise { await this.call( - TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + teamsServicePath('deleteAttachment'), DeleteAttachmentRequestSchema, request, 'deleteAttachment', @@ -436,68 +411,41 @@ export class TeamsGrpcClient { path: string, schema: DescMessage, request: Req, - method: keyof TeamsServiceGrpcClientInstance, + method: keyof TeamsServiceClient, timeoutMs?: number, ): Promise { const message = create(schema, request as never) as Req; const fn = this.client[method] as unknown as UnaryRpcCall; - return this.unary( - path, - message, - (req, metadata, options, callback) => { - if (options) { - fn(req, metadata, options, callback); - return; - } - fn(req, metadata, callback); - }, - timeoutMs, - ); + return this.unary(path, message, fn, timeoutMs); } private unary( path: string, request: Request, - invoke: ( - request: Request, - metadata: Metadata, - options: CallOptions | undefined, - callback: (err: ServiceError | null, response?: Response) => void, - ) => void, + invoke: (request: Request, options?: CallOptions) => Promise, timeoutMs?: number, ): Promise { - const metadata = this.createMetadata(); const callOptions = this.buildCallOptions(timeoutMs); - return new Promise((resolve, reject) => { - const callback = (err: ServiceError | null, response?: Response) => { - if (err) { - reject(this.translateServiceError(err, { path })); - return; - } - resolve(response as Response); - }; - invoke(request, metadata, callOptions, callback); + return invoke(request, callOptions).catch((error) => { + throw this.translateServiceError(error, { path }); }); } private buildCallOptions(timeoutMs?: number): CallOptions | undefined { const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : this.requestTimeoutMs; if (!timeout || timeout <= 0) return undefined; - return { deadline: new Date(Date.now() + timeout) }; - } - - private createMetadata(): Metadata { - return new Metadata(); + return { timeoutMs: timeout }; } - private translateServiceError(error: ServiceError, context?: { path?: string }): HttpException { - const grpcCode = typeof error.code === 'number' ? error.code : status.UNKNOWN; - const statusName = (status as unknown as Record)[grpcCode] ?? 'UNKNOWN'; - const message = this.extractServiceErrorMessage(error); + private translateServiceError(error: unknown, context?: { path?: string }): HttpException { + const connectError = ConnectError.from(error); + const grpcCode = connectError.code ?? Code.Unknown; + const statusName = Code[grpcCode] ?? 'UNKNOWN'; + const message = this.extractServiceErrorMessage(connectError); const httpStatus = this.grpcStatusToHttpStatus(grpcCode); const errorCode = this.grpcStatusToErrorCode(grpcCode); const path = context?.path ?? 'unknown'; - if (grpcCode === status.UNIMPLEMENTED) { + if (grpcCode === Code.Unimplemented) { this.logger.error('Teams gRPC call returned UNIMPLEMENTED', { path, grpcStatus: statusName, @@ -517,78 +465,79 @@ export class TeamsGrpcClient { return new TeamsGrpcRequestError(httpStatus, grpcCode, errorCode, message); } - private grpcStatusToHttpStatus(grpcCode: status): HttpStatus { + private grpcStatusToHttpStatus(grpcCode: Code): HttpStatus { switch (grpcCode) { - case status.INVALID_ARGUMENT: + case Code.InvalidArgument: return HttpStatus.BAD_REQUEST; - case status.UNAUTHENTICATED: + case Code.Unauthenticated: return HttpStatus.UNAUTHORIZED; - case status.PERMISSION_DENIED: + case Code.PermissionDenied: return HttpStatus.FORBIDDEN; - case status.NOT_FOUND: + case Code.NotFound: return HttpStatus.NOT_FOUND; - case status.ABORTED: - case status.ALREADY_EXISTS: + case Code.Aborted: + case Code.AlreadyExists: return HttpStatus.CONFLICT; - case status.FAILED_PRECONDITION: + case Code.FailedPrecondition: return HttpStatus.PRECONDITION_FAILED; - case status.RESOURCE_EXHAUSTED: + case Code.ResourceExhausted: return HttpStatus.TOO_MANY_REQUESTS; - case status.UNIMPLEMENTED: + case Code.Unimplemented: return HttpStatus.NOT_IMPLEMENTED; - case status.INTERNAL: - case status.DATA_LOSS: + case Code.Internal: + case Code.DataLoss: return HttpStatus.INTERNAL_SERVER_ERROR; - case status.UNAVAILABLE: + case Code.Unavailable: return HttpStatus.SERVICE_UNAVAILABLE; - case status.DEADLINE_EXCEEDED: + case Code.DeadlineExceeded: return HttpStatus.GATEWAY_TIMEOUT; - case status.OUT_OF_RANGE: + case Code.OutOfRange: return HttpStatus.BAD_REQUEST; - case status.CANCELLED: + case Code.Canceled: return 499 as HttpStatus; default: return HttpStatus.BAD_GATEWAY; } } - private grpcStatusToErrorCode(grpcCode: status): string { + private grpcStatusToErrorCode(grpcCode: Code): string { switch (grpcCode) { - case status.INVALID_ARGUMENT: + case Code.InvalidArgument: return 'teams_invalid_argument'; - case status.UNAUTHENTICATED: + case Code.Unauthenticated: return 'teams_unauthenticated'; - case status.PERMISSION_DENIED: + case Code.PermissionDenied: return 'teams_forbidden'; - case status.NOT_FOUND: + case Code.NotFound: return 'teams_not_found'; - case status.ABORTED: - case status.ALREADY_EXISTS: + case Code.Aborted: + case Code.AlreadyExists: return 'teams_conflict'; - case status.FAILED_PRECONDITION: + case Code.FailedPrecondition: return 'teams_failed_precondition'; - case status.RESOURCE_EXHAUSTED: + case Code.ResourceExhausted: return 'teams_resource_exhausted'; - case status.UNIMPLEMENTED: + case Code.Unimplemented: return 'teams_unimplemented'; - case status.INTERNAL: + case Code.Internal: return 'teams_internal_error'; - case status.DATA_LOSS: + case Code.DataLoss: return 'teams_data_loss'; - case status.UNAVAILABLE: + case Code.Unavailable: return 'teams_unavailable'; - case status.DEADLINE_EXCEEDED: + case Code.DeadlineExceeded: return 'teams_timeout'; - case status.CANCELLED: + case Code.Canceled: return 'teams_cancelled'; default: return 'teams_grpc_error'; } } - private extractServiceErrorMessage(error: ServiceError): string { - const details = typeof error.details === 'string' ? error.details.trim() : ''; - const message = details || error.message || DEFAULT_ERROR_MESSAGE; - return message.trim() || DEFAULT_ERROR_MESSAGE; + private extractServiceErrorMessage(error: ConnectError): string { + const details = typeof error.rawMessage === 'string' ? error.rawMessage.trim() : ''; + if (details) return details; + const sanitized = error.message.replace(/^\[[^\]]+\]\s*/, '').trim(); + return sanitized || DEFAULT_ERROR_MESSAGE; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d9daa2d3..99efec321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,55 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.19)(jiti@2.5.1)(jsdom@27.1.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@20.19.19)(typescript@5.4.5))(tsx@4.20.5)(yaml@2.8.1) + packages/docker-runner: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.11.0 + version: 2.11.0 + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.11.0) + '@connectrpc/connect-node': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.11.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.11.0)) + '@nestjs/common': + specifier: ^11.1.7 + version: 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dockerode: + specifier: ^4.0.8 + version: 4.0.8 + dotenv: + specifier: ^17.2.2 + version: 17.2.2 + uuid: + specifier: ^13.0.0 + version: 13.0.0 + zod: + specifier: ^4.1.9 + version: 4.1.12 + devDependencies: + '@types/dockerode': + specifier: ^3.3.44 + version: 3.3.44 + '@types/node': + specifier: ^24.5.1 + version: 24.5.2 + eslint: + specifier: ^9.13.0 + version: 9.36.0(jiti@2.5.1) + tsx: + specifier: ^4.20.5 + version: 4.20.5 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.8.1 + version: 8.44.0(eslint@9.36.0(jiti@2.5.1))(typescript@5.8.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.5.2)(jiti@2.5.1)(jsdom@27.1.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(tsx@4.20.5)(yaml@2.8.1) + packages/json-schema-to-zod: dependencies: zod: @@ -84,6 +133,9 @@ importers: packages/platform-server: dependencies: + '@agyn/docker-runner': + specifier: workspace:* + version: link:../docker-runner '@agyn/json-schema-to-zod': specifier: workspace:* version: link:../json-schema-to-zod @@ -96,15 +148,18 @@ importers: '@bufbuild/protobuf': specifier: ^2.11.0 version: 2.11.0 + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.11.0) + '@connectrpc/connect-node': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.11.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.11.0)) '@fastify/cors': specifier: ^11.1.0 version: 11.1.0 '@fastify/websocket': specifier: ^11.2.0 version: 11.2.0 - '@grpc/grpc-js': - specifier: ^1.12.2 - version: 1.14.0 '@langchain/core': specifier: 1.0.1 version: 1.0.1(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.6.0(ws@8.18.3)(zod@4.1.12)) @@ -855,6 +910,9 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -901,6 +959,18 @@ packages: peerDependencies: commander: ~13.1.0 + '@connectrpc/connect-node@2.1.1': + resolution: {integrity: sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.1 + + '@connectrpc/connect@2.1.1': + resolution: {integrity: sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1390,6 +1460,11 @@ packages: resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} engines: {node: '>=12.10.0'} + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@grpc/proto-loader@0.8.0': resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} engines: {node: '>=6'} @@ -3750,6 +3825,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@3.3.44': + resolution: {integrity: sha512-fUpIHlsbYpxAJb285xx3vp7q5wf5mjqSn3cYwl/MhiM+DB99OdO5sOCPlO0PjO+TyOtphPs7tMVLU/RtOo/JjA==} + '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} @@ -3804,6 +3885,9 @@ packages: '@types/mustache@4.2.6': resolution: {integrity: sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==} + '@types/node@18.19.127': + resolution: {integrity: sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==} + '@types/node@20.19.19': resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} @@ -3834,6 +3918,9 @@ packages: resolution: {integrity: sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==} deprecated: This is a stub types definition. socket.io provides its own type definitions, so you do not need this installed. + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -4228,6 +4315,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4320,12 +4410,18 @@ packages: resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -4354,6 +4450,13 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buildcheck@0.0.6: + resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4460,6 +4563,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4627,6 +4733,10 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4861,6 +4971,14 @@ packages: dnd-core@16.0.1: resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} + docker-modem@5.0.6: + resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} + engines: {node: '>= 8.0'} + + dockerode@4.0.8: + resolution: {integrity: sha512-HdPBprWmwfHMHi12AVIFDhXIqIS+EpiOVkZaAZxgML4xf5McqEZjJZtahTPkLDxWOt84ApfWPAH9EoQwOiaAIQ==} + engines: {node: '>= 8.0'} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -5370,6 +5488,9 @@ packages: fromentries@1.3.2: resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-exists-sync@0.1.0: resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} engines: {node: '>=0.10.0'} @@ -6550,6 +6671,9 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -6589,6 +6713,9 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nan@2.23.0: + resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7574,6 +7701,9 @@ packages: spawnd@5.0.0: resolution: {integrity: sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA==} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -7581,6 +7711,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -7753,6 +7887,13 @@ packages: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -7889,6 +8030,9 @@ packages: tw-animate-css@1.3.8: resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -7945,6 +8089,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8624,6 +8771,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@0.2.3': {} '@borewit/text-codec@0.1.1': {} @@ -8674,6 +8823,15 @@ snapshots: dependencies: commander: 13.1.0 + '@connectrpc/connect-node@2.1.1(@bufbuild/protobuf@2.11.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.11.0))': + dependencies: + '@bufbuild/protobuf': 2.11.0 + '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.11.0) + + '@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.11.0)': + dependencies: + '@bufbuild/protobuf': 2.11.0 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -9083,6 +9241,13 @@ snapshots: '@grpc/proto-loader': 0.8.0 '@js-sdsl/ordered-map': 4.4.2 + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@grpc/proto-loader@0.8.0': dependencies: lodash.camelcase: 4.3.0 @@ -11817,6 +11982,17 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 24.5.2 + '@types/ssh2': 1.15.5 + + '@types/dockerode@3.3.44': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 24.5.2 + '@types/ssh2': 1.15.5 + '@types/doctrine@0.0.9': {} '@types/estree-jsx@1.0.5': @@ -11870,6 +12046,10 @@ snapshots: '@types/mustache@4.2.6': {} + '@types/node@18.19.127': + dependencies: + undici-types: 5.26.5 + '@types/node@20.19.19': dependencies: undici-types: 6.21.0 @@ -11904,6 +12084,10 @@ snapshots: - supports-color - utf-8-validate + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.127 + '@types/stack-utils@2.0.3': {} '@types/statuses@2.0.6': {} @@ -12351,6 +12535,10 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types@0.16.1: @@ -12458,12 +12646,22 @@ snapshots: baseline-browser-mapping@2.8.6: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@4.0.0: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -12507,6 +12705,14 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.6: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -12609,6 +12815,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + chownr@3.0.0: {} chromatic@13.3.4: {} @@ -12749,6 +12957,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.6 + nan: 2.23.0 + optional: true + create-require@1.1.1: {} cross-spawn@7.0.6: @@ -12962,6 +13176,27 @@ snapshots: '@react-dnd/invariant': 4.0.2 redux: 4.2.1 + docker-modem@5.0.6: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@4.0.8: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.0 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.6 + protobufjs: 7.5.4 + tar-fs: 2.1.4 + uuid: 10.0.0 + transitivePeerDependencies: + - supports-color + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -13581,6 +13816,8 @@ snapshots: fromentries@1.3.2: {} + fs-constants@1.0.0: {} + fs-exists-sync@0.1.0: {} fs.realpath@1.0.0: {} @@ -15183,6 +15420,8 @@ snapshots: dependencies: minipass: 7.1.2 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -15255,6 +15494,9 @@ snapshots: mute-stream@2.0.0: {} + nan@2.23.0: + optional: true + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -16396,10 +16638,20 @@ snapshots: transitivePeerDependencies: - supports-color + split-ca@1.0.1: {} + split2@4.2.0: {} sprintf-js@1.0.3: {} + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.23.0 + stack-trace@0.0.10: {} stack-utils@2.0.6: @@ -16571,6 +16823,21 @@ snapshots: tapable@2.2.3: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -16706,6 +16973,8 @@ snapshots: tw-animate-css@1.3.8: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -16751,6 +17020,8 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@7.12.0: {} From 44b78e12db6516f17439bf4a19acd938e898c7eb Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 11 Mar 2026 06:15:45 +0000 Subject: [PATCH 06/43] fix(tests): close runner sessions --- .../__tests__/helpers/docker.e2e.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/platform-server/__tests__/helpers/docker.e2e.ts b/packages/platform-server/__tests__/helpers/docker.e2e.ts index 78e8c16a5..f14330311 100644 --- a/packages/platform-server/__tests__/helpers/docker.e2e.ts +++ b/packages/platform-server/__tests__/helpers/docker.e2e.ts @@ -7,7 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises'; import { create } from '@bufbuild/protobuf'; import { createClient, type Client, type Interceptor } from '@connectrpc/connect'; import { createGrpcTransport, Http2SessionManager } from '@connectrpc/connect-node'; -import type { Http2Server } from 'node:http2'; +import type { Http2Server, ServerHttp2Session } from 'node:http2'; import { createRunnerGrpcServer } from '../../../docker-runner/src/service/grpc/server'; import { ContainerService, NonceCache, buildAuthHeaders } from '../../../docker-runner/src'; @@ -29,6 +29,30 @@ export type PostgresHandle = { }; type RunnerServiceClient = Client; +const serverSessions = new WeakMap>(); + +function registerRunnerServerSessions(server: Http2Server): void { + const sessions = new Set(); + serverSessions.set(server, sessions); + server.on('session', (session) => { + sessions.add(session); + session.once('close', () => sessions.delete(session)); + }); +} + +function closeRunnerServerConnections(server: Http2Server): void { + const closeAllConnections = (server as { closeAllConnections?: () => void }).closeAllConnections; + if (typeof closeAllConnections === 'function') { + closeAllConnections.call(server); + return; + } + const sessions = serverSessions.get(server); + if (!sessions) return; + for (const session of sessions) { + session.destroy(); + } + sessions.clear(); +} export async function startDockerRunner(socketPath: string): Promise { const grpcPort = await getAvailablePort(); @@ -51,6 +75,7 @@ export async function startDockerRunner(socketPath: string): Promise { await new Promise((resolve) => { server.close(() => resolve()); + closeRunnerServerConnections(server); }); } From 86078f0bfb299ff2aae21173f1ee85fffced8b20 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 11 Mar 2026 07:08:44 +0000 Subject: [PATCH 07/43] fix(runner): stabilize exec teardown --- .../docker-runner/src/service/grpc/server.ts | 48 ++++++++++++++++++- ...ntainers.delete.docker.integration.test.ts | 22 +++++++-- ...iners.fullstack.docker.integration.test.ts | 13 +++-- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/docker-runner/src/service/grpc/server.ts b/packages/docker-runner/src/service/grpc/server.ts index 837b88fcf..c7916e52a 100644 --- a/packages/docker-runner/src/service/grpc/server.ts +++ b/packages/docker-runner/src/service/grpc/server.ts @@ -81,6 +81,13 @@ type ExecutionContext = { const activeExecutions = new Map(); +const shouldDebugExec = process.env.DEBUG_RUNNER_EXEC === '1'; + +const logExec = (message: string, details: Record = {}) => { + if (!shouldDebugExec) return; + console.info(`[runner exec] ${message}`, details); +}; + const runnerServicePath = (method: keyof typeof RunnerService.method): string => `/${RunnerService.typeName}/${RunnerService.method[method].name}`; @@ -861,6 +868,11 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { const closeResponses = () => { if (closed) return; + logExec('closeResponses', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + }); closed = true; responseQueue.end(); }; @@ -871,6 +883,12 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { const finish = async (target: ExecutionContext, reason: ExecExitReason, killed = false) => { if (!target || target.finished) return; + logExec('finish', { + executionId: target.executionId, + requestId: target.requestId, + reason, + killed, + }); target.finished = true; target.reason = reason; target.killed = killed; @@ -1167,26 +1185,46 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { await handleRequest(req); if (closed || ctx?.finished) break; } + logExec('handleRequests completed', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); if (!ctx || closed) { closeResponses(); return; } if (ctx.finished) { - closeResponses(); return; } return; } catch { - if (!ctx || ctx.finished || closed) { + logExec('handleRequests error', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); + if (!ctx || closed) { closeResponses(); return; } + if (ctx.finished) { + return; + } clearExecutionTimers(ctx); await finish(ctx, ExecExitReason.RUNNER_ERROR, ctx.killed); } })(); const onAbort = () => { + logExec('context aborted', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); if (!ctx || ctx.finished || closed) return; ctx.cancelRequested = true; clearExecutionTimers(ctx); @@ -1199,6 +1237,12 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { yield response; } } finally { + logExec('response loop closing', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); context.signal.removeEventListener('abort', onAbort); closeResponses(); await handleRequests.catch(() => undefined); diff --git a/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts b/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts index e991137d8..cafaa67af 100644 --- a/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts +++ b/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { randomUUID } from 'node:crypto'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Http2SessionManager } from '@connectrpc/connect-node'; import { Test } from '@nestjs/testing'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; @@ -23,6 +24,7 @@ import { runnerSecretMissing, socketMissing, startDockerRunner, + startDockerRunnerProcess, startPostgres, runPrismaMigrations, type RunnerHandle, @@ -43,6 +45,7 @@ describeOrSkip('DELETE /api/containers/:id docker runner integration', () => { let registry: ContainerRegistry; let runner: RunnerHandle; let dockerClient: RunnerGrpcClient; + let sessionManager: Http2SessionManager | undefined; let dbHandle: PostgresHandle; const orphanContainers = new Set(); const startRegisteredContainer = async (prefix: string): Promise<{ containerId: string }> => { @@ -79,8 +82,11 @@ describeOrSkip('DELETE /api/containers/:id docker runner integration', () => { registry = new ContainerRegistry(prisma); await registry.ensureIndexes(); - runner = await startDockerRunner(); - dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET }); + const socketPath = socketMissing && hasTcpDocker ? '' : DEFAULT_SOCKET; + runner = await startDockerRunner(socketPath); + const baseUrl = runner.grpcAddress.startsWith('http') ? runner.grpcAddress : `http://${runner.grpcAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET, sessionManager }); const moduleRef = await Test.createTestingModule({ controllers: [ContainersController], @@ -105,6 +111,8 @@ describeOrSkip('DELETE /api/containers/:id docker runner integration', () => { afterAll(async () => { const adapter = app?.getHttpAdapter?.().getInstance?.(); + sessionManager?.abort(); + sessionManager = undefined; if (app) { await app.close(); } @@ -252,6 +260,7 @@ describeOrSkip('DELETE /api/containers/:id docker runner external process integr let registry: ContainerRegistry; let runner: RunnerHandle; let dockerClient: RunnerGrpcClient; + let sessionManager: Http2SessionManager | undefined; let dbHandle: PostgresHandle; const orphanContainers = new Set(); @@ -289,8 +298,11 @@ describeOrSkip('DELETE /api/containers/:id docker runner external process integr registry = new ContainerRegistry(prisma); await registry.ensureIndexes(); - runner = await startDockerRunner(); - dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET }); + const socketPath = socketMissing && hasTcpDocker ? '' : DEFAULT_SOCKET; + runner = await startDockerRunnerProcess(socketPath); + const baseUrl = runner.grpcAddress.startsWith('http') ? runner.grpcAddress : `http://${runner.grpcAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET, sessionManager }); const moduleRef = await Test.createTestingModule({ controllers: [ContainersController], @@ -314,6 +326,8 @@ describeOrSkip('DELETE /api/containers/:id docker runner external process integr }, 120_000); afterAll(async () => { + sessionManager?.abort(); + sessionManager = undefined; if (app) { await app.close(); } diff --git a/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts b/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts index 3290ba5b1..cef53f487 100644 --- a/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts +++ b/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { randomUUID } from 'node:crypto'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Http2SessionManager } from '@connectrpc/connect-node'; import { Test } from '@nestjs/testing'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { Body, Controller, Post } from '@nestjs/common'; @@ -24,7 +25,7 @@ import { runnerAddressMissing, runnerSecretMissing, socketMissing, - startDockerRunner, + startDockerRunnerProcess, startPostgres, runPrismaMigrations, waitFor, @@ -75,6 +76,7 @@ describeOrSkip('workspace create → delete full-stack flow', () => { let runner: RunnerHandle; let dbHandle: PostgresHandle; let dockerClient: RunnerGrpcClient; + let sessionManager: Http2SessionManager | undefined; let configService: ConfigService; const createdThreads = new Set(); const createdContainers = new Set(); @@ -83,8 +85,11 @@ describeOrSkip('workspace create → delete full-stack flow', () => { dbHandle = await startPostgres(); await runPrismaMigrations(dbHandle.connectionString); - runner = await startDockerRunner(); - dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET }); + const socketPath = socketMissing && hasTcpDocker ? '' : DEFAULT_SOCKET; + runner = await startDockerRunnerProcess(socketPath); + const baseUrl = runner.grpcAddress.startsWith('http') ? runner.grpcAddress : `http://${runner.grpcAddress}`; + sessionManager = new Http2SessionManager(baseUrl); + dockerClient = new RunnerGrpcClient({ address: runner.grpcAddress, sharedSecret: RUNNER_SECRET, sessionManager }); clearTestConfig(); const [grpcHost, grpcPort] = runner.grpcAddress.split(':'); @@ -146,6 +151,8 @@ describeOrSkip('workspace create → delete full-stack flow', () => { }); afterAll(async () => { + sessionManager?.abort(); + sessionManager = undefined; if (app) { await app.close(); } From 4dc3ad5b207669adcf6a751358869ec0c7bf771b Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Thu, 12 Mar 2026 11:41:57 +0000 Subject: [PATCH 08/43] chore(platform-server): update devspace (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(platform-server): update devspace * fix(platform-server): align devspace workflow * fix(platform-server): move devspace ports * fix(devspace): sync to /opt/app/data to avoid CRI permission issue The container runtime creates /opt/app/data/workspace as root:node 2755 which is not writable by UID 1000. Using the emptyDir mount root /opt/app/data directly resolves this since Kubernetes sets it to 2777 with fsGroup. * fix(devspace): bootstrap pnpm via corepack The current platform-server dev image does not ship with pnpm shims on PATH. Install the shims under /opt/app/data so the non-root dev container can run pnpm during startup. * fix(devspace): bootstrap buf and graph path * chore(devspace): extract startup script * fix(devspace): add transitional pnpm fallback for stale dev image Until the dev image is rebuilt from the updated Dockerfile.dev, corepack's pnpm shim isn't on PATH. This adds a conditional fallback that will be a no-op once the image includes PNPM_HOME. * fix(devspace): use corepack shims for pnpm Avoid writing to /usr/local/bin when pnpm is missing on PATH. This prepends corepack's shims directory so stale dev images can activate pnpm without permission errors. * fix(devspace): remove transitional fallbacks, build image locally for testing * fix(devspace): use tsx watch for hot reload in dev mode * docs(devspace): fix startup step 5 to match tsx watch command * docs(devspace): remove KUBECONFIG export — apply.sh merges into ~/.kube * chore(devspace): use ghcr.io/agynio/devcontainer-node:1 for dev image Extract the dev container image to its own repository (agynio/devcontainer-node) with proper versioning and CI. Remove Dockerfile.dev and the platform-server-dev-image workflow from this repo. * fix(platform-server): stabilize litellm aliases * fix(platform-server): patch devspace in place * fix(platform): stabilize exec completion - poll exec inspect to finish gRPC streams - add exec inspect helper for docker runner - align devspace patch indentation * revert: remove out-of-scope docker-runner changes * fix: stabilize exec polling and litellm * revert: remove out-of-scope docker-runner changes (again) * fix(platform-server): avoid ncps init block * fix(platform-server): restore ncps init Add LiteLLM provisioner integration coverage. Update LiteLLM test script and helpers. * test(platform-server): add litellm error paths Add mocked provisioner error path coverage and fix key store delete in integration test. * Add .devspace to gitignore --------- Co-authored-by: Vitalii Valkov --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 1e571b974..3434c50df 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -16,6 +16,7 @@ const trackedEnvKeys = [ 'AGENTS_DEPLOYMENT', 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', + 'DEPLOYMENT_ID', 'NODE_ENV', 'HOSTNAME', ]; From ad7043f612beb6b920e0f43fdc2bf43ee75b676e Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 09/43] feat(platform-server): add teams grpc client --- package.json | 2 +- .../__tests__/config.service.fromEnv.test.ts | 1 + packages/platform-server/src/proto/grpc.ts | 409 +++++++++++++++++ proto/agynio/api/teams/v1/teams.proto | 421 ++++++++++++++++++ proto/buf.yaml | 3 + 5 files changed, 835 insertions(+), 1 deletion(-) create mode 100644 packages/platform-server/src/proto/grpc.ts create mode 100644 proto/agynio/api/teams/v1/teams.proto create mode 100644 proto/buf.yaml diff --git a/package.json b/package.json index f62a26d19..ae40743f1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "pnpm -r --workspace-concurrency=1 run --if-present test", "convert-graphs": "pnpm --filter @agyn/graph-converter exec graph-converter", "postinstall": "pnpm -r --if-present run prepare", - "proto:generate": "buf generate buf.build/agynio/api", + "proto:generate": "buf generate buf.build/agynio/api && buf generate proto", "deps:up:podman": "podman compose up -d" }, "keywords": [], diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 3434c50df..2b37345d9 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -17,6 +17,7 @@ const trackedEnvKeys = [ 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts new file mode 100644 index 000000000..5a3d37cdb --- /dev/null +++ b/packages/platform-server/src/proto/grpc.ts @@ -0,0 +1,409 @@ +import { makeGenericClientConstructor } from '@grpc/grpc-js'; +import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; +import { toBinary, fromBinary } from '@bufbuild/protobuf'; +import type { DescMessage } from '@bufbuild/protobuf'; +import { EmptySchema } from '@bufbuild/protobuf/wkt'; +import { + CancelExecutionRequestSchema, + CancelExecutionResponseSchema, + ExecRequestSchema, + ExecResponseSchema, + FindWorkloadsByLabelsRequestSchema, + FindWorkloadsByLabelsResponseSchema, + GetWorkloadLabelsRequestSchema, + GetWorkloadLabelsResponseSchema, + InspectWorkloadRequestSchema, + InspectWorkloadResponseSchema, + ListWorkloadsByVolumeRequestSchema, + ListWorkloadsByVolumeResponseSchema, + PutArchiveRequestSchema, + PutArchiveResponseSchema, + ReadyRequestSchema, + ReadyResponseSchema, + RemoveVolumeRequestSchema, + RemoveVolumeResponseSchema, + RemoveWorkloadRequestSchema, + RemoveWorkloadResponseSchema, + StartWorkloadRequestSchema, + StartWorkloadResponseSchema, + StopWorkloadRequestSchema, + StopWorkloadResponseSchema, + StreamEventsRequestSchema, + StreamEventsResponseSchema, + StreamWorkloadLogsRequestSchema, + StreamWorkloadLogsResponseSchema, + TouchWorkloadRequestSchema, + TouchWorkloadResponseSchema, +} from './gen/agynio/api/runner/v1/runner_pb.js'; +import { + AgentCreateRequestSchema, + AgentSchema, + AgentUpdateRequestSchema, + AttachmentCreateRequestSchema, + AttachmentSchema, + DeleteAgentRequestSchema, + DeleteAttachmentRequestSchema, + DeleteMcpServerRequestSchema, + DeleteMemoryBucketRequestSchema, + DeleteToolRequestSchema, + DeleteWorkspaceConfigurationRequestSchema, + GetAgentRequestSchema, + GetMcpServerRequestSchema, + GetMemoryBucketRequestSchema, + GetToolRequestSchema, + GetWorkspaceConfigurationRequestSchema, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + McpServerCreateRequestSchema, + McpServerSchema, + McpServerUpdateRequestSchema, + MemoryBucketCreateRequestSchema, + MemoryBucketSchema, + MemoryBucketUpdateRequestSchema, + PaginatedAgentsSchema, + PaginatedAttachmentsSchema, + PaginatedMcpServersSchema, + PaginatedMemoryBucketsSchema, + PaginatedToolsSchema, + PaginatedWorkspaceConfigurationsSchema, + ToolCreateRequestSchema, + ToolSchema, + ToolUpdateRequestSchema, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationSchema, + WorkspaceConfigurationUpdateRequestSchema, +} from './gen/agynio/api/teams/v1/teams_pb.js'; + +const unaryDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: false, + responseStream: false, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +const serverStreamDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: false, + responseStream: true, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +const bidiDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: true, + responseStream: true, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +export const RUNNER_SERVICE_READY_PATH = '/agynio.api.runner.v1.RunnerService/Ready'; +export const RUNNER_SERVICE_START_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StartWorkload'; +export const RUNNER_SERVICE_STOP_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StopWorkload'; +export const RUNNER_SERVICE_REMOVE_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/RemoveWorkload'; +export const RUNNER_SERVICE_INSPECT_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/InspectWorkload'; +export const RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/GetWorkloadLabels'; +export const RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/FindWorkloadsByLabels'; +export const RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/ListWorkloadsByVolume'; +export const RUNNER_SERVICE_REMOVE_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/RemoveVolume'; +export const RUNNER_SERVICE_TOUCH_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/TouchWorkload'; +export const RUNNER_SERVICE_PUT_ARCHIVE_PATH = '/agynio.api.runner.v1.RunnerService/PutArchive'; +export const RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH = '/agynio.api.runner.v1.RunnerService/StreamWorkloadLogs'; +export const RUNNER_SERVICE_STREAM_EVENTS_PATH = '/agynio.api.runner.v1.RunnerService/StreamEvents'; +export const RUNNER_SERVICE_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/Exec'; +export const RUNNER_SERVICE_CANCEL_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/CancelExecution'; + +export const runnerServiceGrpcDefinition: ServiceDefinition = { + ready: unaryDefinition( + RUNNER_SERVICE_READY_PATH, + ReadyRequestSchema, + ReadyResponseSchema, + ), + startWorkload: unaryDefinition( + RUNNER_SERVICE_START_WORKLOAD_PATH, + StartWorkloadRequestSchema, + StartWorkloadResponseSchema, + ), + stopWorkload: unaryDefinition( + RUNNER_SERVICE_STOP_WORKLOAD_PATH, + StopWorkloadRequestSchema, + StopWorkloadResponseSchema, + ), + removeWorkload: unaryDefinition( + RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, + RemoveWorkloadRequestSchema, + RemoveWorkloadResponseSchema, + ), + inspectWorkload: unaryDefinition( + RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, + InspectWorkloadRequestSchema, + InspectWorkloadResponseSchema, + ), + getWorkloadLabels: unaryDefinition( + RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH, + GetWorkloadLabelsRequestSchema, + GetWorkloadLabelsResponseSchema, + ), + findWorkloadsByLabels: unaryDefinition( + RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH, + FindWorkloadsByLabelsRequestSchema, + FindWorkloadsByLabelsResponseSchema, + ), + listWorkloadsByVolume: unaryDefinition( + RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH, + ListWorkloadsByVolumeRequestSchema, + ListWorkloadsByVolumeResponseSchema, + ), + removeVolume: unaryDefinition( + RUNNER_SERVICE_REMOVE_VOLUME_PATH, + RemoveVolumeRequestSchema, + RemoveVolumeResponseSchema, + ), + touchWorkload: unaryDefinition( + RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, + TouchWorkloadRequestSchema, + TouchWorkloadResponseSchema, + ), + putArchive: unaryDefinition( + RUNNER_SERVICE_PUT_ARCHIVE_PATH, + PutArchiveRequestSchema, + PutArchiveResponseSchema, + ), + streamWorkloadLogs: serverStreamDefinition( + RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH, + StreamWorkloadLogsRequestSchema, + StreamWorkloadLogsResponseSchema, + ), + streamEvents: serverStreamDefinition( + RUNNER_SERVICE_STREAM_EVENTS_PATH, + StreamEventsRequestSchema, + StreamEventsResponseSchema, + ), + exec: bidiDefinition( + RUNNER_SERVICE_EXEC_PATH, + ExecRequestSchema, + ExecResponseSchema, + ), + cancelExecution: unaryDefinition( + RUNNER_SERVICE_CANCEL_EXEC_PATH, + CancelExecutionRequestSchema, + CancelExecutionResponseSchema, + ), +}; + +export const RunnerServiceGrpcClient = makeGenericClientConstructor( + runnerServiceGrpcDefinition, + 'agynio.api.runner.v1.RunnerService', +); + +export type RunnerServiceGrpcClientInstance = InstanceType; + +export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; +export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; +export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; +export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; +export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; +export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; +export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; +export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; +export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; +export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; +export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; +export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; +export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; +export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; +export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; +export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = + '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; +export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; +export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; +export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; +export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; +export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; +export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; +export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; +export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; +export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; +export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; +export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; +export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; + +export const teamsServiceGrpcDefinition: ServiceDefinition = { + listAgents: unaryDefinition( + TEAMS_SERVICE_LIST_AGENTS_PATH, + ListAgentsRequestSchema, + PaginatedAgentsSchema, + ), + createAgent: unaryDefinition( + TEAMS_SERVICE_CREATE_AGENT_PATH, + AgentCreateRequestSchema, + AgentSchema, + ), + getAgent: unaryDefinition( + TEAMS_SERVICE_GET_AGENT_PATH, + GetAgentRequestSchema, + AgentSchema, + ), + updateAgent: unaryDefinition( + TEAMS_SERVICE_UPDATE_AGENT_PATH, + AgentUpdateRequestSchema, + AgentSchema, + ), + deleteAgent: unaryDefinition( + TEAMS_SERVICE_DELETE_AGENT_PATH, + DeleteAgentRequestSchema, + EmptySchema, + ), + listTools: unaryDefinition( + TEAMS_SERVICE_LIST_TOOLS_PATH, + ListToolsRequestSchema, + PaginatedToolsSchema, + ), + createTool: unaryDefinition( + TEAMS_SERVICE_CREATE_TOOL_PATH, + ToolCreateRequestSchema, + ToolSchema, + ), + getTool: unaryDefinition( + TEAMS_SERVICE_GET_TOOL_PATH, + GetToolRequestSchema, + ToolSchema, + ), + updateTool: unaryDefinition( + TEAMS_SERVICE_UPDATE_TOOL_PATH, + ToolUpdateRequestSchema, + ToolSchema, + ), + deleteTool: unaryDefinition( + TEAMS_SERVICE_DELETE_TOOL_PATH, + DeleteToolRequestSchema, + EmptySchema, + ), + listMcpServers: unaryDefinition( + TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + ListMcpServersRequestSchema, + PaginatedMcpServersSchema, + ), + createMcpServer: unaryDefinition( + TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + McpServerCreateRequestSchema, + McpServerSchema, + ), + getMcpServer: unaryDefinition( + TEAMS_SERVICE_GET_MCP_SERVER_PATH, + GetMcpServerRequestSchema, + McpServerSchema, + ), + updateMcpServer: unaryDefinition( + TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + McpServerUpdateRequestSchema, + McpServerSchema, + ), + deleteMcpServer: unaryDefinition( + TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + DeleteMcpServerRequestSchema, + EmptySchema, + ), + listWorkspaceConfigurations: unaryDefinition( + TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + ListWorkspaceConfigurationsRequestSchema, + PaginatedWorkspaceConfigurationsSchema, + ), + createWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationSchema, + ), + getWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + GetWorkspaceConfigurationRequestSchema, + WorkspaceConfigurationSchema, + ), + updateWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, + WorkspaceConfigurationUpdateRequestSchema, + WorkspaceConfigurationSchema, + ), + deleteWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + DeleteWorkspaceConfigurationRequestSchema, + EmptySchema, + ), + listMemoryBuckets: unaryDefinition( + TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + ListMemoryBucketsRequestSchema, + PaginatedMemoryBucketsSchema, + ), + createMemoryBucket: unaryDefinition( + TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + MemoryBucketCreateRequestSchema, + MemoryBucketSchema, + ), + getMemoryBucket: unaryDefinition( + TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + GetMemoryBucketRequestSchema, + MemoryBucketSchema, + ), + updateMemoryBucket: unaryDefinition( + TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + MemoryBucketUpdateRequestSchema, + MemoryBucketSchema, + ), + deleteMemoryBucket: unaryDefinition( + TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + DeleteMemoryBucketRequestSchema, + EmptySchema, + ), + listAttachments: unaryDefinition( + TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + ListAttachmentsRequestSchema, + PaginatedAttachmentsSchema, + ), + createAttachment: unaryDefinition( + TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + AttachmentCreateRequestSchema, + AttachmentSchema, + ), + deleteAttachment: unaryDefinition( + TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + DeleteAttachmentRequestSchema, + EmptySchema, + ), +}; + +export const TeamsServiceGrpcClient = makeGenericClientConstructor( + teamsServiceGrpcDefinition, + 'agynio.api.teams.v1.TeamsService', +); + +export type TeamsServiceGrpcClientInstance = InstanceType; diff --git a/proto/agynio/api/teams/v1/teams.proto b/proto/agynio/api/teams/v1/teams.proto new file mode 100644 index 000000000..573c3d09e --- /dev/null +++ b/proto/agynio/api/teams/v1/teams.proto @@ -0,0 +1,421 @@ +syntax = "proto3"; + +package agynio.api.teams.v1; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +enum AgentWhenBusy { + AGENT_WHEN_BUSY_UNSPECIFIED = 0; + AGENT_WHEN_BUSY_WAIT = 1; + AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS = 2; +} + +enum AgentProcessBuffer { + AGENT_PROCESS_BUFFER_UNSPECIFIED = 0; + AGENT_PROCESS_BUFFER_ALL_TOGETHER = 1; + AGENT_PROCESS_BUFFER_ONE_BY_ONE = 2; +} + +enum ToolType { + TOOL_TYPE_UNSPECIFIED = 0; + TOOL_TYPE_MANAGE = 1; + TOOL_TYPE_MEMORY = 2; + TOOL_TYPE_SHELL_COMMAND = 3; + TOOL_TYPE_SEND_MESSAGE = 4; + TOOL_TYPE_SEND_SLACK_MESSAGE = 5; + TOOL_TYPE_REMIND_ME = 6; + TOOL_TYPE_GITHUB_CLONE_REPO = 7; + TOOL_TYPE_CALL_AGENT = 8; +} + +enum WorkspacePlatform { + WORKSPACE_PLATFORM_UNSPECIFIED = 0; + WORKSPACE_PLATFORM_LINUX_AMD64 = 1; + WORKSPACE_PLATFORM_LINUX_ARM64 = 2; + WORKSPACE_PLATFORM_AUTO = 3; +} + +enum MemoryBucketScope { + MEMORY_BUCKET_SCOPE_UNSPECIFIED = 0; + MEMORY_BUCKET_SCOPE_GLOBAL = 1; + MEMORY_BUCKET_SCOPE_PER_THREAD = 2; +} + +enum EntityType { + ENTITY_TYPE_UNSPECIFIED = 0; + ENTITY_TYPE_AGENT = 1; + ENTITY_TYPE_TOOL = 2; + ENTITY_TYPE_MCP_SERVER = 3; + ENTITY_TYPE_WORKSPACE_CONFIGURATION = 4; + ENTITY_TYPE_MEMORY_BUCKET = 5; +} + +enum AttachmentKind { + ATTACHMENT_KIND_UNSPECIFIED = 0; + ATTACHMENT_KIND_AGENT_TOOL = 1; + ATTACHMENT_KIND_AGENT_MEMORY_BUCKET = 2; + ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION = 3; + ATTACHMENT_KIND_AGENT_MCP_SERVER = 4; + ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION = 5; +} + +message AgentConfig { + string model = 1; + string system_prompt = 2; + uint32 debounce_ms = 3; + AgentWhenBusy when_busy = 4; + AgentProcessBuffer process_buffer = 5; + bool send_final_response_to_thread = 6; + uint32 summarization_keep_tokens = 7; + uint32 summarization_max_tokens = 8; + bool restrict_output = 9; + string restriction_message = 10; + uint32 restriction_max_injections = 11; + string name = 12; + string role = 13; +} + +message Agent { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + AgentConfig config = 6; +} + +message AgentCreateRequest { + string title = 1; + string description = 2; + AgentConfig config = 3; +} + +message AgentUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + AgentConfig config = 4; +} + +message GetAgentRequest { + string id = 1; +} + +message DeleteAgentRequest { + string id = 1; +} + +message ListAgentsRequest { + optional string q = 1; + optional uint32 page = 2; + optional uint32 per_page = 3; +} + +message PaginatedAgents { + repeated Agent items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message Tool { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + ToolType type = 4; + string name = 5; + string description = 6; + google.protobuf.Struct config = 7; +} + +message ToolCreateRequest { + ToolType type = 1; + string name = 2; + string description = 3; + google.protobuf.Struct config = 4; +} + +message ToolUpdateRequest { + string id = 1; + optional string name = 2; + optional string description = 3; + google.protobuf.Struct config = 4; +} + +message GetToolRequest { + string id = 1; +} + +message DeleteToolRequest { + string id = 1; +} + +message ListToolsRequest { + ToolType type = 1; + optional uint32 page = 2; + optional uint32 per_page = 3; +} + +message PaginatedTools { + repeated Tool items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message McpEnvItem { + string name = 1; + string value = 2; +} + +message McpServerRestartPolicy { + uint32 max_attempts = 1; + uint32 backoff_ms = 2; +} + +message McpServerConfig { + string namespace = 1; + string command = 2; + string workdir = 3; + repeated McpEnvItem env = 4; + optional uint32 request_timeout_ms = 5; + optional uint32 startup_timeout_ms = 6; + optional uint32 heartbeat_interval_ms = 7; + optional uint32 stale_timeout_ms = 8; + McpServerRestartPolicy restart = 9; +} + +message McpServer { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + McpServerConfig config = 6; +} + +message McpServerCreateRequest { + string title = 1; + string description = 2; + McpServerConfig config = 3; +} + +message McpServerUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + McpServerConfig config = 4; +} + +message GetMcpServerRequest { + string id = 1; +} + +message DeleteMcpServerRequest { + string id = 1; +} + +message ListMcpServersRequest { + optional uint32 page = 1; + optional uint32 per_page = 2; +} + +message PaginatedMcpServers { + repeated McpServer items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message WorkspaceEnvItem { + string name = 1; + string value = 2; +} + +message WorkspaceVolumeConfig { + bool enabled = 1; + string mount_path = 2; +} + +message WorkspaceConfig { + string image = 1; + repeated WorkspaceEnvItem env = 2; + string initial_script = 3; + google.protobuf.Value cpu_limit = 4; + google.protobuf.Value memory_limit = 5; + WorkspacePlatform platform = 6; + bool enable_dind = 7; + uint32 ttl_seconds = 8; + google.protobuf.Struct nix = 9; + WorkspaceVolumeConfig volumes = 10; +} + +message WorkspaceConfiguration { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + WorkspaceConfig config = 6; +} + +message WorkspaceConfigurationCreateRequest { + string title = 1; + string description = 2; + WorkspaceConfig config = 3; +} + +message WorkspaceConfigurationUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + WorkspaceConfig config = 4; +} + +message GetWorkspaceConfigurationRequest { + string id = 1; +} + +message DeleteWorkspaceConfigurationRequest { + string id = 1; +} + +message ListWorkspaceConfigurationsRequest { + optional uint32 page = 1; + optional uint32 per_page = 2; +} + +message PaginatedWorkspaceConfigurations { + repeated WorkspaceConfiguration items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message MemoryBucketConfig { + MemoryBucketScope scope = 1; + string collection_prefix = 2; +} + +message MemoryBucket { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + string title = 4; + string description = 5; + MemoryBucketConfig config = 6; +} + +message MemoryBucketCreateRequest { + string title = 1; + string description = 2; + MemoryBucketConfig config = 3; +} + +message MemoryBucketUpdateRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + MemoryBucketConfig config = 4; +} + +message GetMemoryBucketRequest { + string id = 1; +} + +message DeleteMemoryBucketRequest { + string id = 1; +} + +message ListMemoryBucketsRequest { + optional uint32 page = 1; + optional uint32 per_page = 2; +} + +message PaginatedMemoryBuckets { + repeated MemoryBucket items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +message Attachment { + string id = 1; + google.protobuf.Timestamp created_at = 2; + google.protobuf.Timestamp updated_at = 3; + AttachmentKind kind = 4; + EntityType source_type = 5; + string source_id = 6; + EntityType target_type = 7; + string target_id = 8; +} + +message AttachmentCreateRequest { + AttachmentKind kind = 1; + string source_id = 2; + string target_id = 3; +} + +message DeleteAttachmentRequest { + string id = 1; +} + +message ListAttachmentsRequest { + EntityType source_type = 1; + optional string source_id = 2; + EntityType target_type = 3; + optional string target_id = 4; + AttachmentKind kind = 5; + optional uint32 page = 6; + optional uint32 per_page = 7; +} + +message PaginatedAttachments { + repeated Attachment items = 1; + uint32 page = 2; + uint32 per_page = 3; + uint64 total = 4; +} + +service TeamsService { + rpc ListAgents(ListAgentsRequest) returns (PaginatedAgents); + rpc CreateAgent(AgentCreateRequest) returns (Agent); + rpc GetAgent(GetAgentRequest) returns (Agent); + rpc UpdateAgent(AgentUpdateRequest) returns (Agent); + rpc DeleteAgent(DeleteAgentRequest) returns (google.protobuf.Empty); + + rpc ListTools(ListToolsRequest) returns (PaginatedTools); + rpc CreateTool(ToolCreateRequest) returns (Tool); + rpc GetTool(GetToolRequest) returns (Tool); + rpc UpdateTool(ToolUpdateRequest) returns (Tool); + rpc DeleteTool(DeleteToolRequest) returns (google.protobuf.Empty); + + rpc ListMcpServers(ListMcpServersRequest) returns (PaginatedMcpServers); + rpc CreateMcpServer(McpServerCreateRequest) returns (McpServer); + rpc GetMcpServer(GetMcpServerRequest) returns (McpServer); + rpc UpdateMcpServer(McpServerUpdateRequest) returns (McpServer); + rpc DeleteMcpServer(DeleteMcpServerRequest) returns (google.protobuf.Empty); + + rpc ListWorkspaceConfigurations(ListWorkspaceConfigurationsRequest) + returns (PaginatedWorkspaceConfigurations); + rpc CreateWorkspaceConfiguration(WorkspaceConfigurationCreateRequest) + returns (WorkspaceConfiguration); + rpc GetWorkspaceConfiguration(GetWorkspaceConfigurationRequest) + returns (WorkspaceConfiguration); + rpc UpdateWorkspaceConfiguration(WorkspaceConfigurationUpdateRequest) + returns (WorkspaceConfiguration); + rpc DeleteWorkspaceConfiguration(DeleteWorkspaceConfigurationRequest) + returns (google.protobuf.Empty); + + rpc ListMemoryBuckets(ListMemoryBucketsRequest) returns (PaginatedMemoryBuckets); + rpc CreateMemoryBucket(MemoryBucketCreateRequest) returns (MemoryBucket); + rpc GetMemoryBucket(GetMemoryBucketRequest) returns (MemoryBucket); + rpc UpdateMemoryBucket(MemoryBucketUpdateRequest) returns (MemoryBucket); + rpc DeleteMemoryBucket(DeleteMemoryBucketRequest) returns (google.protobuf.Empty); + + rpc ListAttachments(ListAttachmentsRequest) returns (PaginatedAttachments); + rpc CreateAttachment(AttachmentCreateRequest) returns (Attachment); + rpc DeleteAttachment(DeleteAttachmentRequest) returns (google.protobuf.Empty); +} diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 000000000..b8699818a --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: . From 2cc14c419d054e0ae768b98f60810293339f64b6 Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Thu, 12 Mar 2026 11:41:57 +0000 Subject: [PATCH 10/43] chore(platform-server): update devspace (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(platform-server): update devspace * fix(platform-server): align devspace workflow * fix(platform-server): move devspace ports * fix(devspace): sync to /opt/app/data to avoid CRI permission issue The container runtime creates /opt/app/data/workspace as root:node 2755 which is not writable by UID 1000. Using the emptyDir mount root /opt/app/data directly resolves this since Kubernetes sets it to 2777 with fsGroup. * fix(devspace): bootstrap pnpm via corepack The current platform-server dev image does not ship with pnpm shims on PATH. Install the shims under /opt/app/data so the non-root dev container can run pnpm during startup. * fix(devspace): bootstrap buf and graph path * chore(devspace): extract startup script * fix(devspace): add transitional pnpm fallback for stale dev image Until the dev image is rebuilt from the updated Dockerfile.dev, corepack's pnpm shim isn't on PATH. This adds a conditional fallback that will be a no-op once the image includes PNPM_HOME. * fix(devspace): use corepack shims for pnpm Avoid writing to /usr/local/bin when pnpm is missing on PATH. This prepends corepack's shims directory so stale dev images can activate pnpm without permission errors. * fix(devspace): remove transitional fallbacks, build image locally for testing * fix(devspace): use tsx watch for hot reload in dev mode * docs(devspace): fix startup step 5 to match tsx watch command * docs(devspace): remove KUBECONFIG export — apply.sh merges into ~/.kube * chore(devspace): use ghcr.io/agynio/devcontainer-node:1 for dev image Extract the dev container image to its own repository (agynio/devcontainer-node) with proper versioning and CI. Remove Dockerfile.dev and the platform-server-dev-image workflow from this repo. * fix(platform-server): stabilize litellm aliases * fix(platform-server): patch devspace in place * fix(platform): stabilize exec completion - poll exec inspect to finish gRPC streams - add exec inspect helper for docker runner - align devspace patch indentation * revert: remove out-of-scope docker-runner changes * fix: stabilize exec polling and litellm * revert: remove out-of-scope docker-runner changes (again) * fix(platform-server): avoid ncps init block * fix(platform-server): restore ncps init Add LiteLLM provisioner integration coverage. Update LiteLLM test script and helpers. * test(platform-server): add litellm error paths Add mocked provisioner error path coverage and fix key store delete in integration test. * Add .devspace to gitignore --------- Co-authored-by: Vitalii Valkov --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 2b37345d9..f84cd3485 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -18,6 +18,7 @@ const trackedEnvKeys = [ 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', + 'DEPLOYMENT_ID', 'NODE_ENV', 'HOSTNAME', ]; From 4f0e6c05e90b396149c3cd64aaea105ce2277c3e Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 11/43] feat(platform-server): add teams grpc client --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index f84cd3485..a11e4d963 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -19,6 +19,7 @@ const trackedEnvKeys = [ 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From 3ab9fc381cbab372cb057510c0f663eecedde4b0 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 12 Mar 2026 21:17:54 +0000 Subject: [PATCH 12/43] feat(platform-ui): migrate entity CRUD --- packages/platform-ui/src/App.tsx | 16 +- packages/platform-ui/src/api/hooks/team.ts | 70 ++ packages/platform-ui/src/api/index.ts | 1 + .../platform-ui/src/api/modules/teamApi.ts | 247 ++++ packages/platform-ui/src/api/types/team.ts | 116 ++ .../components/entities/EntityUpsertForm.tsx | 135 +- .../__tests__/EntityUpsertForm.test.tsx | 643 +++------- .../views/WorkspaceMemoryTemplateView.tsx | 28 +- .../features/entities/api/graphEntities.ts | 418 ------- .../src/features/entities/api/teamEntities.ts | 1105 +++++++++++++++++ .../entities/hooks/useGraphEntities.ts | 150 --- .../entities/hooks/useTeamEntities.ts | 262 ++++ .../src/features/entities/types.ts | 12 +- .../graph/containers/AgentsGraphContainer.tsx | 25 - .../platform-ui/src/layout/RootLayout.tsx | 6 - .../src/layout/__tests__/RootLayout.test.tsx | 2 - .../platform-ui/src/pages/AgentsListPage.tsx | 3 +- .../src/pages/MemoryEntitiesListPage.tsx | 12 +- .../platform-ui/src/pages/OnboardingPage.tsx | 2 +- .../src/pages/TriggersListPage.tsx | 14 - .../src/pages/WorkspacesListPage.tsx | 2 +- .../__tests__/entities-list-page.test.tsx | 788 ++---------- .../src/pages/entities/EntityListPage.tsx | 43 +- .../src/pages/entities/EntityUpsertPage.tsx | 31 +- 24 files changed, 2247 insertions(+), 1884 deletions(-) create mode 100644 packages/platform-ui/src/api/hooks/team.ts create mode 100644 packages/platform-ui/src/api/modules/teamApi.ts create mode 100644 packages/platform-ui/src/api/types/team.ts delete mode 100644 packages/platform-ui/src/features/entities/api/graphEntities.ts create mode 100644 packages/platform-ui/src/features/entities/api/teamEntities.ts delete mode 100644 packages/platform-ui/src/features/entities/hooks/useGraphEntities.ts create mode 100644 packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts delete mode 100644 packages/platform-ui/src/features/graph/containers/AgentsGraphContainer.tsx delete mode 100644 packages/platform-ui/src/pages/TriggersListPage.tsx diff --git a/packages/platform-ui/src/App.tsx b/packages/platform-ui/src/App.tsx index 4bc1c7446..46879cb5c 100644 --- a/packages/platform-ui/src/App.tsx +++ b/packages/platform-ui/src/App.tsx @@ -21,11 +21,9 @@ import { LLMProvidersListPage } from './pages/LLMProvidersListPage'; import { LLMProviderUpsertPage } from './pages/LLMProviderUpsertPage'; import { LLMModelsListPage } from './pages/LLMModelsListPage'; import { LLMModelUpsertPage } from './pages/LLMModelUpsertPage'; -import { AgentsGraphContainer } from './features/graph/containers/AgentsGraphContainer'; import { OnboardingPage } from './pages/OnboardingPage'; import { OnboardingGate } from './features/onboarding/components/OnboardingGate'; import { AgentsListPage } from './pages/AgentsListPage'; -import { TriggersListPage } from './pages/TriggersListPage'; import { ToolsListPage } from './pages/ToolsListPage'; import { WorkspacesListPage } from './pages/WorkspacesListPage'; import { MemoryEntitiesListPage } from './pages/MemoryEntitiesListPage'; @@ -35,7 +33,7 @@ import { EntitySecretsListPage } from './pages/EntitySecretsListPage'; import { SecretProviderUpsertPage } from './pages/SecretProviderUpsertPage'; import { EntitySecretUpsertPage } from './pages/EntitySecretUpsertPage'; import { EntityUpsertPage } from './pages/entities/EntityUpsertPage'; -import { EXCLUDED_WORKSPACE_TEMPLATES, INCLUDED_MEMORY_WORKSPACE_TEMPLATES } from './features/entities/api/graphEntities'; +import { EXCLUDED_WORKSPACE_TEMPLATES, INCLUDED_MEMORY_TEMPLATES } from './features/entities/api/teamEntities'; const queryClient = new QueryClient(); @@ -54,7 +52,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> @@ -63,9 +60,6 @@ function App() { } /> {/* Entities */} - } /> - } /> - } /> } /> } /> } /> @@ -100,10 +94,10 @@ function App() { path="/memory/new" element={( )} /> @@ -111,10 +105,10 @@ function App() { path="/memory/:entityId/edit" element={( )} /> diff --git a/packages/platform-ui/src/api/hooks/team.ts b/packages/platform-ui/src/api/hooks/team.ts new file mode 100644 index 000000000..2b3d8c458 --- /dev/null +++ b/packages/platform-ui/src/api/hooks/team.ts @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query'; + +import * as teamApi from '../modules/teamApi'; +import type { + TeamAgent, + TeamAttachment, + TeamMemoryBucket, + TeamMcpServer, + TeamTool, + TeamWorkspaceConfiguration, +} from '../types/team'; + +const DEFAULT_STALE_TIME = 15_000; + +export const TEAM_QUERY_KEYS = { + agents: ['team', 'agents'] as const, + tools: ['team', 'tools'] as const, + mcpServers: ['team', 'mcpServers'] as const, + workspaceConfigurations: ['team', 'workspaceConfigurations'] as const, + memoryBuckets: ['team', 'memoryBuckets'] as const, + attachments: ['team', 'attachments'] as const, +}; + +export function useTeamAgents() { + return useQuery({ + queryKey: TEAM_QUERY_KEYS.agents, + queryFn: () => teamApi.listAllAgents(), + staleTime: DEFAULT_STALE_TIME, + }); +} + +export function useTeamTools() { + return useQuery({ + queryKey: TEAM_QUERY_KEYS.tools, + queryFn: () => teamApi.listAllTools(), + staleTime: DEFAULT_STALE_TIME, + }); +} + +export function useTeamMcpServers() { + return useQuery({ + queryKey: TEAM_QUERY_KEYS.mcpServers, + queryFn: () => teamApi.listAllMcpServers(), + staleTime: DEFAULT_STALE_TIME, + }); +} + +export function useTeamWorkspaceConfigurations() { + return useQuery({ + queryKey: TEAM_QUERY_KEYS.workspaceConfigurations, + queryFn: () => teamApi.listAllWorkspaceConfigurations(), + staleTime: DEFAULT_STALE_TIME, + }); +} + +export function useTeamMemoryBuckets() { + return useQuery({ + queryKey: TEAM_QUERY_KEYS.memoryBuckets, + queryFn: () => teamApi.listAllMemoryBuckets(), + staleTime: DEFAULT_STALE_TIME, + }); +} + +export function useTeamAttachments() { + return useQuery({ + queryKey: TEAM_QUERY_KEYS.attachments, + queryFn: () => teamApi.listAllAttachments(), + staleTime: DEFAULT_STALE_TIME, + }); +} diff --git a/packages/platform-ui/src/api/index.ts b/packages/platform-ui/src/api/index.ts index 479fd111f..716a07fda 100644 --- a/packages/platform-ui/src/api/index.ts +++ b/packages/platform-ui/src/api/index.ts @@ -2,3 +2,4 @@ export { http } from './http'; export { graph } from './modules/graph'; export * as nix from './modules/nix'; +export * as teamApi from './modules/teamApi'; diff --git a/packages/platform-ui/src/api/modules/teamApi.ts b/packages/platform-ui/src/api/modules/teamApi.ts new file mode 100644 index 000000000..b2b39fc1e --- /dev/null +++ b/packages/platform-ui/src/api/modules/teamApi.ts @@ -0,0 +1,247 @@ +import { http } from '../http'; +import type { + TeamAgent, + TeamAttachment, + TeamListResponse, + TeamMemoryBucket, + TeamMcpServer, + TeamTool, + TeamWorkspaceConfiguration, +} from '../types/team'; + +const TEAM_API_PREFIX = '/apiv2/team/v1'; +const DEFAULT_PAGE_SIZE = 200; + +export type TeamListParams = { + pageToken?: string; + pageSize?: number; +}; + +type PageInfo = { + nextPageToken?: string; + page?: number; + perPage?: number; + total?: number; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + return undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function readPageInfo(record: Record): PageInfo { + const nextPageToken = readString(record.nextPageToken ?? record.next_page_token); + const page = readNumber(record.page ?? record.pageNumber ?? record.page_number); + const perPage = readNumber(record.perPage ?? record.per_page); + const total = readNumber(record.total); + return { nextPageToken, page, perPage, total }; +} + +function getItems(record: Record, key: string): T[] { + const raw = record[key] ?? record.items; + return Array.isArray(raw) ? (raw as T[]) : []; +} + +function parseListResponse(payload: unknown, key: string): TeamListResponse { + if (!isRecord(payload)) { + return { items: [] }; + } + const items = getItems(payload, key); + const pageInfo = readPageInfo(payload); + return { items, ...pageInfo }; +} + +function buildListParams(params?: TeamListParams): Record { + if (!params) return {}; + const result: Record = {}; + if (params.pageSize !== undefined) { + result.pageSize = params.pageSize; + result.page_size = params.pageSize; + result.per_page = params.pageSize; + } + if (params.pageToken !== undefined) { + result.pageToken = params.pageToken; + result.page_token = params.pageToken; + const parsed = Number(params.pageToken); + if (Number.isFinite(parsed)) { + result.page = parsed; + } + } + return result; +} + +function resolveNextPageToken(pageInfo: PageInfo, pageSize: number, currentPage: number, count: number): string | undefined { + if (pageInfo.nextPageToken) return pageInfo.nextPageToken; + if (pageInfo.page !== undefined && pageInfo.perPage !== undefined && pageInfo.total !== undefined) { + if (pageInfo.page * pageInfo.perPage >= pageInfo.total) { + return undefined; + } + return String(pageInfo.page + 1); + } + if (count < pageSize) { + return undefined; + } + return String(currentPage + 1); +} + +async function listAllPages( + fetchPage: (params: TeamListParams) => Promise>, + pageSize = DEFAULT_PAGE_SIZE, +): Promise { + const items: T[] = []; + let pageToken: string | undefined = undefined; + let pageIndex = 1; + for (let i = 0; i < 50; i += 1) { + const response = await fetchPage({ pageSize, pageToken }); + items.push(...response.items); + const nextToken = resolveNextPageToken(response, pageSize, pageIndex, response.items.length); + if (!nextToken) break; + pageToken = nextToken; + pageIndex += 1; + } + return items; +} + +export async function listAgents(params?: TeamListParams): Promise> { + const payload = await http.get(`${TEAM_API_PREFIX}/agents`, { params: buildListParams(params) }); + return parseListResponse(payload, 'agents'); +} + +export async function listAllAgents(): Promise { + return listAllPages(listAgents); +} + +export async function createAgent(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/agents`, payload); +} + +export async function updateAgent(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); +} + +export async function deleteAgent(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/agents/${id}`); +} + +export async function listTools(params?: TeamListParams): Promise> { + const payload = await http.get(`${TEAM_API_PREFIX}/tools`, { params: buildListParams(params) }); + return parseListResponse(payload, 'tools'); +} + +export async function listAllTools(): Promise { + return listAllPages(listTools); +} + +export async function createTool(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/tools`, payload); +} + +export async function updateTool(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); +} + +export async function deleteTool(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/tools/${id}`); +} + +export async function listMcpServers(params?: TeamListParams): Promise> { + const payload = await http.get(`${TEAM_API_PREFIX}/mcp-servers`, { params: buildListParams(params) }); + return parseListResponse(payload, 'mcpServers'); +} + +export async function listAllMcpServers(): Promise { + return listAllPages(listMcpServers); +} + +export async function createMcpServer(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); +} + +export async function updateMcpServer(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); +} + +export async function deleteMcpServer(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/mcp-servers/${id}`); +} + +export async function listWorkspaceConfigurations( + params?: TeamListParams, +): Promise> { + const payload = await http.get(`${TEAM_API_PREFIX}/workspace-configurations`, { + params: buildListParams(params), + }); + return parseListResponse(payload, 'workspaceConfigurations'); +} + +export async function listAllWorkspaceConfigurations(): Promise { + return listAllPages(listWorkspaceConfigurations); +} + +export async function createWorkspaceConfiguration(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); +} + +export async function updateWorkspaceConfiguration( + id: string, + payload: Record, +): Promise { + return http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); +} + +export async function deleteWorkspaceConfiguration(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/workspace-configurations/${id}`); +} + +export async function listMemoryBuckets(params?: TeamListParams): Promise> { + const payload = await http.get(`${TEAM_API_PREFIX}/memory-buckets`, { params: buildListParams(params) }); + return parseListResponse(payload, 'memoryBuckets'); +} + +export async function listAllMemoryBuckets(): Promise { + return listAllPages(listMemoryBuckets); +} + +export async function createMemoryBucket(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); +} + +export async function updateMemoryBucket(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); +} + +export async function deleteMemoryBucket(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/memory-buckets/${id}`); +} + +export async function listAttachments(params?: TeamListParams): Promise> { + const payload = await http.get(`${TEAM_API_PREFIX}/attachments`, { params: buildListParams(params) }); + return parseListResponse(payload, 'attachments'); +} + +export async function listAllAttachments(): Promise { + return listAllPages(listAttachments); +} + +export async function createAttachment(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/attachments`, payload); +} + +export async function deleteAttachment(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/attachments/${id}`); +} diff --git a/packages/platform-ui/src/api/types/team.ts b/packages/platform-ui/src/api/types/team.ts new file mode 100644 index 000000000..de47505b4 --- /dev/null +++ b/packages/platform-ui/src/api/types/team.ts @@ -0,0 +1,116 @@ +export type TeamAgentWhenBusy = + | 'AGENT_WHEN_BUSY_UNSPECIFIED' + | 'AGENT_WHEN_BUSY_WAIT' + | 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; + +export type TeamAgentProcessBuffer = + | 'AGENT_PROCESS_BUFFER_UNSPECIFIED' + | 'AGENT_PROCESS_BUFFER_ALL_TOGETHER' + | 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; + +export type TeamToolType = + | 'TOOL_TYPE_UNSPECIFIED' + | 'TOOL_TYPE_MANAGE' + | 'TOOL_TYPE_MEMORY' + | 'TOOL_TYPE_SHELL_COMMAND' + | 'TOOL_TYPE_SEND_MESSAGE' + | 'TOOL_TYPE_SEND_SLACK_MESSAGE' + | 'TOOL_TYPE_REMIND_ME' + | 'TOOL_TYPE_GITHUB_CLONE_REPO' + | 'TOOL_TYPE_CALL_AGENT'; + +export type TeamWorkspacePlatform = + | 'WORKSPACE_PLATFORM_UNSPECIFIED' + | 'WORKSPACE_PLATFORM_LINUX_AMD64' + | 'WORKSPACE_PLATFORM_LINUX_ARM64' + | 'WORKSPACE_PLATFORM_AUTO'; + +export type TeamMemoryBucketScope = + | 'MEMORY_BUCKET_SCOPE_UNSPECIFIED' + | 'MEMORY_BUCKET_SCOPE_GLOBAL' + | 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + +export type TeamEntityType = + | 'ENTITY_TYPE_UNSPECIFIED' + | 'ENTITY_TYPE_AGENT' + | 'ENTITY_TYPE_TOOL' + | 'ENTITY_TYPE_MCP_SERVER' + | 'ENTITY_TYPE_WORKSPACE_CONFIGURATION' + | 'ENTITY_TYPE_MEMORY_BUCKET'; + +export type TeamAttachmentKind = + | 'ATTACHMENT_KIND_UNSPECIFIED' + | 'ATTACHMENT_KIND_AGENT_TOOL' + | 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET' + | 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION' + | 'ATTACHMENT_KIND_AGENT_MCP_SERVER' + | 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION'; + +export interface TeamListResponse { + items: T[]; + nextPageToken?: string; + page?: number; + perPage?: number; + total?: number; +} + +export interface TeamAgent { + id?: string; + createdAt?: string; + updatedAt?: string; + title?: string; + description?: string; + config?: Record | null; + meta?: { id?: string }; +} + +export interface TeamTool { + id?: string; + createdAt?: string; + updatedAt?: string; + type?: TeamToolType | string | number; + name?: string; + description?: string; + config?: Record | null; + meta?: { id?: string }; +} + +export interface TeamMcpServer { + id?: string; + createdAt?: string; + updatedAt?: string; + title?: string; + description?: string; + config?: Record | null; + meta?: { id?: string }; +} + +export interface TeamWorkspaceConfiguration { + id?: string; + createdAt?: string; + updatedAt?: string; + title?: string; + description?: string; + config?: Record | null; + meta?: { id?: string }; +} + +export interface TeamMemoryBucket { + id?: string; + createdAt?: string; + updatedAt?: string; + title?: string; + description?: string; + config?: Record | null; + meta?: { id?: string }; +} + +export interface TeamAttachment { + id?: string; + kind?: TeamAttachmentKind | string | number; + sourceId?: string; + targetId?: string; + sourceType?: TeamEntityType | string | number; + targetType?: TeamEntityType | string | number; + meta?: { id?: string }; +} diff --git a/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx b/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx index e22db7756..cd89a31da 100644 --- a/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx +++ b/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx @@ -24,9 +24,15 @@ import type { } from '@/features/entities/types'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; import { useMcpNodeState } from '@/lib/graph/hooks'; -import { listTargetsByEdge, sanitizeConfigForPersistence } from '@/features/entities/api/graphEntities'; +import { + EXCLUDED_WORKSPACE_TEMPLATES, + TEAM_ATTACHMENT_KIND, + listTargetsByEdge, + sanitizeConfigForPersistence, +} from '@/features/entities/api/teamEntities'; import { MultiSelectDropdown } from '@/components/MultiSelectDropdown'; import { getUuid } from '@/utils/getUuid'; +import type { TeamAttachmentKind } from '@/api/types/team'; type EntityFormValues = { template: string; @@ -54,6 +60,7 @@ type RelationOwnerRole = GraphRelationOwnerRole; interface RelationCandidateFilter { kinds?: GraphEntityKind[]; templateNames?: string[]; + excludeTemplateNames?: string[]; } interface RelationAppliesTo { @@ -72,6 +79,7 @@ interface RelationFieldDefinition { mode: RelationSelectionMode; candidateFilter: RelationCandidateFilter; placeholder?: string; + attachmentKind: TeamAttachmentKind; } interface RelationOption { @@ -80,18 +88,6 @@ interface RelationOption { } const RELATION_FIELD_DEFINITIONS: RelationFieldDefinition[] = [ - { - id: 'slackTriggerAgent', - label: 'Agent destination', - description: 'Routes Slack trigger events to the selected agent.', - appliesTo: { templateNames: ['slackTrigger'] }, - ownerRole: 'source', - ownerHandle: 'subscribe', - peerHandle: '$self', - mode: 'single', - candidateFilter: { kinds: ['agent'] }, - placeholder: 'Select an agent', - }, { id: 'agentTools', label: 'Tools', @@ -102,6 +98,7 @@ const RELATION_FIELD_DEFINITIONS: RelationFieldDefinition[] = [ peerHandle: '$self', mode: 'multi', candidateFilter: { kinds: ['tool'] }, + attachmentKind: TEAM_ATTACHMENT_KIND.agentTool, }, { id: 'agentMcpServers', @@ -113,101 +110,45 @@ const RELATION_FIELD_DEFINITIONS: RelationFieldDefinition[] = [ peerHandle: '$self', mode: 'multi', candidateFilter: { kinds: ['mcp'] }, + attachmentKind: TEAM_ATTACHMENT_KIND.agentMcpServer, }, { - id: 'agentMemoryConnector', - label: 'Memory connector', - description: 'Bind the agent to a memory connector.', + id: 'agentWorkspaceConfiguration', + label: 'Workspace configuration', + description: 'Select the workspace configuration for this agent.', appliesTo: { templateKinds: ['agent'] }, - ownerRole: 'target', - ownerHandle: 'memory', - peerHandle: '$self', - mode: 'single', - candidateFilter: { templateNames: ['memoryConnector'] }, - placeholder: 'Select a memory connector', - }, - { - id: 'shellToolWorkspace', - label: 'Workspace', - description: 'Provide the workspace for this Shell tool.', - appliesTo: { templateNames: ['shellTool'] }, - ownerRole: 'target', - ownerHandle: 'workspace', - peerHandle: '$self', - mode: 'single', - candidateFilter: { kinds: ['workspace'] }, - placeholder: 'Select a workspace', - }, - { - id: 'githubCloneWorkspace', - label: 'Workspace', - description: 'Provide the workspace for this GitHub clone tool.', - appliesTo: { templateNames: ['githubCloneRepoTool'] }, - ownerRole: 'target', + ownerRole: 'source', ownerHandle: 'workspace', peerHandle: '$self', mode: 'single', - candidateFilter: { kinds: ['workspace'] }, - placeholder: 'Select a workspace', - }, - { - id: 'memoryToolMemory', - label: 'Memory workspace', - description: 'Select the memory backing this tool.', - appliesTo: { templateNames: ['memoryTool'] }, - ownerRole: 'target', - ownerHandle: '$memory', - peerHandle: '$self', - mode: 'single', - candidateFilter: { templateNames: ['memory'] }, - placeholder: 'Select a memory', + candidateFilter: { kinds: ['workspace'], excludeTemplateNames: Array.from(EXCLUDED_WORKSPACE_TEMPLATES) }, + placeholder: 'Select a workspace configuration', + attachmentKind: TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration, }, { - id: 'manageToolAgents', - label: 'Managed agents', - description: 'Pick agents that can be orchestrated by this tool.', - appliesTo: { templateNames: ['manageTool'] }, + id: 'agentMemoryBuckets', + label: 'Memory buckets', + description: 'Attach memory buckets this agent can access.', + appliesTo: { templateKinds: ['agent'] }, ownerRole: 'source', - ownerHandle: 'agent', + ownerHandle: 'memory', peerHandle: '$self', mode: 'multi', - candidateFilter: { kinds: ['agent'] }, - }, - { - id: 'callAgentToolAgent', - label: 'Agent', - description: 'Select the agent to call.', - appliesTo: { templateNames: ['callAgentTool'] }, - ownerRole: 'source', - ownerHandle: 'agent', - peerHandle: '$self', - mode: 'single', - candidateFilter: { kinds: ['agent'] }, - placeholder: 'Select an agent', + candidateFilter: { templateNames: ['memory'] }, + attachmentKind: TEAM_ATTACHMENT_KIND.agentMemoryBucket, }, { id: 'mcpServerWorkspace', - label: 'Workspace', - description: 'Select the workspace hosting this MCP server.', - appliesTo: { templateNames: ['mcpServer'] }, - ownerRole: 'target', + label: 'Workspace configuration', + description: 'Select the workspace configuration hosting this MCP server.', + appliesTo: { templateKinds: ['mcp'] }, + ownerRole: 'source', ownerHandle: 'workspace', peerHandle: '$self', mode: 'single', - candidateFilter: { kinds: ['workspace'] }, - placeholder: 'Select a workspace', - }, - { - id: 'memoryConnectorMemory', - label: 'Memory workspace', - description: 'Select the memory backing this connector.', - appliesTo: { templateNames: ['memoryConnector'] }, - ownerRole: 'target', - ownerHandle: '$memory', - peerHandle: '$self', - mode: 'single', - candidateFilter: { templateNames: ['memory'] }, - placeholder: 'Select a memory', + candidateFilter: { kinds: ['workspace'], excludeTemplateNames: Array.from(EXCLUDED_WORKSPACE_TEMPLATES) }, + placeholder: 'Select a workspace configuration', + attachmentKind: TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration, }, ]; @@ -220,7 +161,7 @@ const NODE_KIND_TO_ENTITY_KIND: Record = { }; function matchesCandidateFilter(node: GraphNodeConfig, filter: RelationCandidateFilter): boolean { - if (!filter.kinds && !filter.templateNames) { + if (!filter.kinds && !filter.templateNames && !filter.excludeTemplateNames) { return true; } if (filter.kinds && filter.kinds.length > 0) { @@ -234,6 +175,11 @@ function matchesCandidateFilter(node: GraphNodeConfig, filter: RelationCandidate return false; } } + if (filter.excludeTemplateNames && filter.excludeTemplateNames.length > 0) { + if (filter.excludeTemplateNames.includes(node.template)) { + return false; + } + } return true; } @@ -292,6 +238,7 @@ function toNodeKind(rawKind?: string | GraphEntityKind | null): NodeViewKind { return 'Tool'; case 'mcp': return 'MCP'; + case 'memory': case 'workspace': case 'service': default: @@ -866,15 +813,17 @@ export function EntityUpsertForm({ mode: definition.mode, selections: normalizedSelections, ownerId: entity?.id, + attachmentKind: definition.attachmentKind, } satisfies GraphEntityRelationInput; }); const payload: GraphEntityUpsertInput = { id: entity?.id, + entityKind: kind, template: templateName, title: payloadTitle, config: payloadConfig, - relations: relationPayload.length > 0 ? relationPayload : undefined, + relations: relationDefinitions.length > 0 ? relationPayload : undefined, } satisfies GraphEntityUpsertInput; try { diff --git a/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx b/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx index 37f151890..cf435124b 100644 --- a/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx +++ b/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx @@ -39,6 +39,7 @@ import type { GraphEntityKind, GraphEntitySummary, TemplateOption } from '@/feat import type { PersistedGraphNode } from '@agyn/shared'; import type { TemplateSchema } from '@/api/types/graph'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; +import { TEAM_ATTACHMENT_KIND } from '@/features/entities/api/teamEntities'; function createTemplate(name: string, kind: GraphEntityKind = 'workspace'): TemplateOption { const schema: TemplateSchema = { @@ -72,6 +73,7 @@ function createEntitySummary(overrides: Partial = {}): Graph return { id: 'node-1', + entityKind: 'workspace', node, title: 'Node 1', templateName: 'template-1', @@ -112,58 +114,7 @@ function createGraphEdge(overrides: Partial & { source: stri } satisfies GraphPersistedEdge; } -interface RelationDialogRenderOptions { - kind: GraphEntityKind; - templateName: string; - graphNodes: GraphNodeConfig[]; - graphEdges: GraphPersistedEdge[]; - entity?: GraphEntitySummary; - mode?: 'create' | 'edit'; - templates?: TemplateOption[]; -} - -function renderRelationDialog(options: RelationDialogRenderOptions) { - const { - kind, - templateName, - graphNodes, - graphEdges, - entity, - mode = 'edit', - templates = [createTemplate(templateName, kind)], - } = options; - const resolvedEntity = - entity ?? - createEntitySummary({ - id: `${templateName}-entity`, - templateName, - templateKind: kind, - rawTemplateKind: kind, - title: `${templateName} entity`, - }); - const onSubmit = vi.fn().mockResolvedValue(undefined); - - render( - - - , - ); - - return { onSubmit }; -} - const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }); - describe('EntityUpsertForm', () => { it('embeds workspace config fields and submits updated values', async () => { const templates = [createTemplate('workspace-template', 'workspace')]; @@ -205,6 +156,7 @@ describe('EntityUpsertForm', () => { const payload = onSubmit.mock.calls[0][0]; expect(payload).toMatchObject({ + entityKind: 'workspace', template: 'workspace-template', title: 'My Workspace', }); @@ -220,12 +172,56 @@ describe('EntityUpsertForm', () => { const templates = [createTemplate('agent-template', 'agent')]; const entity = createEntitySummary({ id: 'agent-1', + entityKind: 'agent', + templateName: 'agent-template', + templateKind: 'agent', + rawTemplateKind: 'agent', + title: 'Agent 1', + }); + + render( + + + , + ); + + const templateSelect = await screen.findByLabelText('Template'); + expect(templateSelect).toBeDisabled(); + expect(templateSelect).toHaveTextContent('agent-template-title'); + + await screen.findByText('Profile'); + }); + + it('includes attachment relations for agent selections', async () => { + const templates = [createTemplate('agent-template', 'agent')]; + const entity = createEntitySummary({ + id: 'agent-1', + entityKind: 'agent', templateName: 'agent-template', templateKind: 'agent', rawTemplateKind: 'agent', - title: 'Existing Agent', - config: { title: 'Existing Agent', template: 'agent-template', kind: 'Agent' }, + title: 'Agent 1', }); + const graphNodes = [ + createGraphNode({ id: 'tool-1', kind: 'Tool', template: 'manageTool', title: 'Manage tool' }), + createGraphNode({ id: 'mcp-1', kind: 'MCP', template: 'mcpServer', title: 'Filesystem MCP' }), + createGraphNode({ id: 'workspace-1', kind: 'Workspace', template: 'workspace', title: 'Worker Pool' }), + createGraphNode({ id: 'memory-1', kind: 'Workspace', template: 'memory', title: 'Global Memory' }), + ]; + const graphEdges = [ + createGraphEdge({ source: 'agent-1', sourceHandle: 'tools', target: 'tool-1' }), + createGraphEdge({ source: 'agent-1', sourceHandle: 'mcp', target: 'mcp-1' }), + createGraphEdge({ source: 'agent-1', sourceHandle: 'workspace', target: 'workspace-1' }), + createGraphEdge({ source: 'agent-1', sourceHandle: 'memory', target: 'memory-1' }), + ]; const onSubmit = vi.fn().mockResolvedValue(undefined); const user = setupUser(); @@ -238,6 +234,8 @@ describe('EntityUpsertForm', () => { entity={entity} onSubmit={onSubmit} isSubmitting={false} + graphNodes={graphNodes} + graphEdges={graphEdges} onCancel={vi.fn()} /> , @@ -260,12 +258,34 @@ describe('EntityUpsertForm', () => { }); const payload = onSubmit.mock.calls[0][0]; + expect(payload.entityKind).toBe('agent'); expect(payload.template).toBe('agent-template'); - expect(payload.title).toBe('Existing Agent'); + expect(payload.title).toBe(entity.title); expect(payload.config).toMatchObject({ model: 'claude-3' }); expect(payload.config).not.toHaveProperty('title'); expect(payload.config).not.toHaveProperty('template'); expect(payload.config).not.toHaveProperty('kind'); + const relations = payload.relations ?? []; + expect(relations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'agentTools', selections: ['tool-1'], attachmentKind: 'agent_tool' }), + expect.objectContaining({ + id: 'agentMcpServers', + selections: ['mcp-1'], + attachmentKind: 'agent_mcpServer', + }), + expect.objectContaining({ + id: 'agentWorkspaceConfiguration', + selections: ['workspace-1'], + attachmentKind: 'agent_workspaceConfiguration', + }), + expect.objectContaining({ + id: 'agentMemoryBuckets', + selections: ['memory-1'], + attachmentKind: 'agent_memoryBucket', + }), + ]), + ); await user.clear(modelInput); await user.type(modelInput, 'qwen-plus'); @@ -276,13 +296,90 @@ describe('EntityUpsertForm', () => { }); const secondPayload = onSubmit.mock.calls[1][0]; - expect(secondPayload.title).toBe('Existing Agent'); + expect(secondPayload.title).toBe(entity.title); expect(secondPayload.config).toMatchObject({ model: 'qwen-plus' }); expect(secondPayload.config).not.toHaveProperty('title'); expect(secondPayload.config).not.toHaveProperty('template'); expect(secondPayload.config).not.toHaveProperty('kind'); }); + it('includes attachment relations for agent selections', async () => { + const templates = [createTemplate('agent-template', 'agent')]; + const entity = createEntitySummary({ + id: 'agent-1', + entityKind: 'agent', + templateName: 'agent-template', + templateKind: 'agent', + rawTemplateKind: 'agent', + title: 'Agent 1', + }); + const graphNodes = [ + createGraphNode({ id: 'tool-1', kind: 'Tool', template: 'manageTool', title: 'Manage tool' }), + createGraphNode({ id: 'mcp-1', kind: 'MCP', template: 'mcpServer', title: 'Filesystem MCP' }), + createGraphNode({ id: 'workspace-1', kind: 'Workspace', template: 'workspace', title: 'Worker Pool' }), + createGraphNode({ id: 'memory-1', kind: 'Workspace', template: 'memory', title: 'Global Memory' }), + ]; + const graphEdges = [ + createGraphEdge({ source: 'agent-1', sourceHandle: 'tools', target: 'tool-1' }), + createGraphEdge({ source: 'agent-1', sourceHandle: 'mcp', target: 'mcp-1' }), + createGraphEdge({ source: 'agent-1', sourceHandle: 'workspace', target: 'workspace-1' }), + createGraphEdge({ source: 'agent-1', sourceHandle: 'memory', target: 'memory-1' }), + ]; + const onSubmit = vi.fn().mockResolvedValue(undefined); + const user = setupUser(); + + render( + + + , + ); + + const submitButton = await screen.findByRole('button', { name: /save changes/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + const payload = onSubmit.mock.calls[0][0]; + const relations = payload.relations ?? []; + expect(relations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'agentTools', + selections: ['tool-1'], + attachmentKind: TEAM_ATTACHMENT_KIND.agentTool, + }), + expect.objectContaining({ + id: 'agentMcpServers', + selections: ['mcp-1'], + attachmentKind: TEAM_ATTACHMENT_KIND.agentMcpServer, + }), + expect.objectContaining({ + id: 'agentWorkspaceConfiguration', + selections: ['workspace-1'], + attachmentKind: TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration, + }), + expect.objectContaining({ + id: 'agentMemoryBuckets', + selections: ['memory-1'], + attachmentKind: TEAM_ATTACHMENT_KIND.agentMemoryBucket, + attachmentKind: TEAM_ATTACHMENT_KIND.agentMemoryBucket, + }), + ]), + ); + }); + it('falls back to config title and strips env sources before submit', async () => { const templates = [createTemplate('worker-service', 'workspace')]; const entity = createEntitySummary({ @@ -344,422 +441,60 @@ describe('EntityUpsertForm', () => { expect(entry).not.toHaveProperty('source'); }); }); -}); -describe('EntityUpsertForm relations', () => { - it('prefills Slack trigger agent relation and persists edits', async () => { - const graphNodes = [ - createGraphNode({ id: 'trigger-1', template: 'slackTrigger', kind: 'Trigger', title: 'Slack Trigger' }), - createGraphNode({ id: 'agent-1', template: 'support-agent', kind: 'Agent', title: 'Agent One' }), - createGraphNode({ id: 'agent-2', template: 'support-agent', kind: 'Agent', title: 'Agent Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'trigger-1', sourceHandle: 'subscribe', target: 'agent-1', targetHandle: '$self' }), - ]; - const entity = createEntitySummary({ - id: 'trigger-1', - templateName: 'slackTrigger', - templateKind: 'trigger', - rawTemplateKind: 'trigger', - title: 'Slack Trigger', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'trigger', - templateName: 'slackTrigger', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const relationSelect = await screen.findByLabelText('Agent destination'); - await user.click(relationSelect); - await screen.findByRole('option', { name: 'Agent One' }); - await waitFor(() => { - expect(screen.getByRole('option', { name: 'Agent One' })).toHaveAttribute('data-state', 'checked'); - }); - await user.click(await screen.findByRole('option', { name: 'Agent Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'slackTriggerAgent'); - expect(relation).toMatchObject({ - ownerRole: 'source', - ownerHandle: 'subscribe', - peerHandle: '$self', - selections: ['agent-2'], - }); - }); - - it('updates agent tool relations via multi-select', async () => { - const graphNodes = [ - createGraphNode({ id: 'agent-1', template: 'support-agent', kind: 'Agent', title: 'Agent One' }), - createGraphNode({ id: 'tool-1', template: 'shellTool', kind: 'Tool', title: 'Tool One' }), - createGraphNode({ id: 'tool-2', template: 'githubCloneRepoTool', kind: 'Tool', title: 'Tool Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'agent-1', sourceHandle: 'tools', target: 'tool-1', targetHandle: '$self' }), - ]; - const entity = createEntitySummary({ - id: 'agent-1', - templateName: 'support-agent', - templateKind: 'agent', - rawTemplateKind: 'agent', - title: 'Agent One', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'agent', - templateName: 'support-agent', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const toolsDropdown = await screen.findByRole('combobox', { name: 'Tools' }); - expect(screen.getByLabelText('Remove Tool One')).toBeInTheDocument(); - await user.click(toolsDropdown); - const toolsListbox = await screen.findByRole('listbox'); - expect(toolsListbox).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Tool Two' })); - await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); - expect(screen.getByText('Tool Two')).toBeVisible(); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const toolsRelation = payload.relations?.find((item) => item.id === 'agentTools'); - expect(toolsRelation).toMatchObject({ - ownerRole: 'source', - ownerHandle: 'tools', - selections: expect.arrayContaining(['tool-1', 'tool-2']), - }); - }); - - it('updates agent MCP server relations via multi-select', async () => { - const graphNodes = [ - createGraphNode({ id: 'agent-1', template: 'support-agent', kind: 'Agent', title: 'Agent One' }), - createGraphNode({ id: 'mcp-1', template: 'mcpServer', kind: 'MCP', title: 'MCP One' }), - createGraphNode({ id: 'mcp-2', template: 'mcpServer', kind: 'MCP', title: 'MCP Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'agent-1', sourceHandle: 'mcp', target: 'mcp-1', targetHandle: '$self' }), - ]; - const entity = createEntitySummary({ - id: 'agent-1', - templateName: 'support-agent', - templateKind: 'agent', - rawTemplateKind: 'agent', - title: 'Agent One', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'agent', - templateName: 'support-agent', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const mcpDropdown = await screen.findByRole('combobox', { name: 'MCP servers' }); - expect(screen.getByLabelText('Remove MCP One')).toBeInTheDocument(); - await user.click(mcpDropdown); - await user.click(screen.getByRole('button', { name: 'MCP Two' })); - expect(screen.getByLabelText('Remove MCP Two')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'agentMcpServers'); - expect(relation).toMatchObject({ selections: expect.arrayContaining(['mcp-1', 'mcp-2']) }); - }); - - it('updates agent memory connector relation', async () => { - const graphNodes = [ - createGraphNode({ id: 'agent-1', template: 'support-agent', kind: 'Agent', title: 'Agent One' }), - createGraphNode({ id: 'mc-1', template: 'memoryConnector', kind: 'Workspace', title: 'Connector One' }), - createGraphNode({ id: 'mc-2', template: 'memoryConnector', kind: 'Workspace', title: 'Connector Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'mc-1', sourceHandle: '$self', target: 'agent-1', targetHandle: 'memory' }), - ]; - const entity = createEntitySummary({ - id: 'agent-1', - templateName: 'support-agent', - templateKind: 'agent', - rawTemplateKind: 'agent', - title: 'Agent One', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'agent', - templateName: 'support-agent', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const select = await screen.findByLabelText('Memory connector'); - await user.click(select); - await screen.findByRole('option', { name: 'Connector One' }); - await waitFor(() => { - expect(screen.getByRole('option', { name: 'Connector One' })).toHaveAttribute('data-state', 'checked'); - }); - await user.click(await screen.findByRole('option', { name: 'Connector Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'agentMemoryConnector'); - expect(relation).toMatchObject({ - ownerRole: 'target', - ownerHandle: 'memory', - peerHandle: '$self', - selections: ['mc-2'], - }); - }); - - it('updates shell tool workspace relation', async () => { - const graphNodes = [ - createGraphNode({ id: 'shell-1', template: 'shellTool', kind: 'Tool', title: 'Shell Tool' }), - createGraphNode({ id: 'workspace-1', template: 'workspace-default', kind: 'Workspace', title: 'Workspace One' }), - createGraphNode({ id: 'workspace-2', template: 'workspace-other', kind: 'Workspace', title: 'Workspace Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'workspace-1', sourceHandle: '$self', target: 'shell-1', targetHandle: 'workspace' }), - ]; - const entity = createEntitySummary({ - id: 'shell-1', - templateName: 'shellTool', - templateKind: 'tool', - rawTemplateKind: 'tool', - title: 'Shell Tool', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'tool', - templateName: 'shellTool', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const select = await screen.findByLabelText('Workspace'); - await user.click(select); - await screen.findByRole('option', { name: 'Workspace One' }); - await waitFor(() => { - expect(screen.getByRole('option', { name: 'Workspace One' })).toHaveAttribute('data-state', 'checked'); - }); - await user.click(await screen.findByRole('option', { name: 'Workspace Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'shellToolWorkspace'); - expect(relation).toMatchObject({ selections: ['workspace-2'] }); - }); - - it('updates manage tool agent selections', async () => { - const graphNodes = [ - createGraphNode({ id: 'manage-1', template: 'manageTool', kind: 'Tool', title: 'Manage Tool' }), - createGraphNode({ id: 'agent-1', template: 'support-agent', kind: 'Agent', title: 'Agent One' }), - createGraphNode({ id: 'agent-2', template: 'support-agent', kind: 'Agent', title: 'Agent Two' }), - createGraphNode({ id: 'agent-3', template: 'support-agent', kind: 'Agent', title: 'Agent Three' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'manage-1', sourceHandle: 'agent', target: 'agent-1', targetHandle: '$self' }), - createGraphEdge({ source: 'manage-1', sourceHandle: 'agent', target: 'agent-2', targetHandle: '$self' }), - ]; - const entity = createEntitySummary({ - id: 'manage-1', - templateName: 'manageTool', - templateKind: 'tool', - rawTemplateKind: 'tool', - title: 'Manage Tool', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'tool', - templateName: 'manageTool', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const agentsDropdown = await screen.findByRole('combobox', { name: 'Managed agents' }); - expect(screen.getByLabelText('Remove Agent One')).toBeInTheDocument(); - expect(screen.getByLabelText('Remove Agent Two')).toBeInTheDocument(); - await user.click(agentsDropdown); - await user.click(screen.getByRole('button', { name: 'Agent Three' })); - await user.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'manageToolAgents'); - expect(relation).toMatchObject({ selections: expect.arrayContaining(['agent-1', 'agent-2', 'agent-3']) }); - }); - - it('updates call agent tool relation', async () => { - const graphNodes = [ - createGraphNode({ id: 'call-tool-1', template: 'callAgentTool', kind: 'Tool', title: 'Call Agent Tool' }), - createGraphNode({ id: 'agent-1', template: 'support-agent', kind: 'Agent', title: 'Agent One' }), - createGraphNode({ id: 'agent-2', template: 'support-agent', kind: 'Agent', title: 'Agent Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'call-tool-1', sourceHandle: 'agent', target: 'agent-1', targetHandle: '$self' }), - ]; - const entity = createEntitySummary({ - id: 'call-tool-1', - templateName: 'callAgentTool', - templateKind: 'tool', - rawTemplateKind: 'tool', - title: 'Call Agent Tool', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'tool', - templateName: 'callAgentTool', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); - - const select = await screen.findByLabelText(/^Agent$/); - await user.click(select); - await screen.findByRole('option', { name: 'Agent One' }); - await waitFor(() => { - expect(screen.getByRole('option', { name: 'Agent One' })).toHaveAttribute('data-state', 'checked'); - }); - await user.click(await screen.findByRole('option', { name: 'Agent Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'callAgentToolAgent'); - expect(relation).toMatchObject({ selections: ['agent-2'] }); - }); - - it('updates MCP server workspace relation', async () => { - const graphNodes = [ - createGraphNode({ id: 'mcp-1', template: 'mcpServer', kind: 'MCP', title: 'MCP Server' }), - createGraphNode({ id: 'workspace-1', template: 'workspace-default', kind: 'Workspace', title: 'Workspace One' }), - createGraphNode({ id: 'workspace-2', template: 'workspace-other', kind: 'Workspace', title: 'Workspace Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'workspace-1', sourceHandle: '$self', target: 'mcp-1', targetHandle: 'workspace' }), - ]; + it('includes workspace attachment for MCP servers', async () => { + const templates = [createTemplate('mcp-template', 'mcp')]; const entity = createEntitySummary({ id: 'mcp-1', - templateName: 'mcpServer', + entityKind: 'mcp', + templateName: 'mcp-template', templateKind: 'mcp', rawTemplateKind: 'mcp', - title: 'MCP Server', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'mcp', - templateName: 'mcpServer', - graphNodes, - graphEdges, - entity, + title: 'Filesystem MCP', }); - const user = setupUser(); - - const select = await screen.findByLabelText('Workspace'); - await user.click(select); - await screen.findByRole('option', { name: 'Workspace One' }); - await waitFor(() => { - expect(screen.getByRole('option', { name: 'Workspace One' })).toHaveAttribute('data-state', 'checked'); - }); - await user.click(await screen.findByRole('option', { name: 'Workspace Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'mcpServerWorkspace'); - expect(relation).toMatchObject({ selections: ['workspace-2'] }); - }); - - it('updates memory tool memory relation', async () => { const graphNodes = [ - createGraphNode({ id: 'memory-tool-1', template: 'memoryTool', kind: 'Tool', title: 'Memory Tool' }), - createGraphNode({ id: 'memory-1', template: 'memory', kind: 'Workspace', title: 'Memory One' }), - createGraphNode({ id: 'memory-2', template: 'memory', kind: 'Workspace', title: 'Memory Two' }), + createGraphNode({ id: 'workspace-1', kind: 'Workspace', template: 'workspace', title: 'Workspace One' }), + createGraphNode({ id: 'memory-1', kind: 'Workspace', template: 'memory', title: 'Memory Bucket' }), ]; const graphEdges = [ - createGraphEdge({ source: 'memory-1', sourceHandle: '$self', target: 'memory-tool-1', targetHandle: '$memory' }), + createGraphEdge({ source: 'mcp-1', sourceHandle: 'workspace', target: 'workspace-1' }), ]; - const entity = createEntitySummary({ - id: 'memory-tool-1', - templateName: 'memoryTool', - templateKind: 'tool', - rawTemplateKind: 'tool', - title: 'Memory Tool', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'tool', - templateName: 'memoryTool', - graphNodes, - graphEdges, - entity, - }); + const onSubmit = vi.fn().mockResolvedValue(undefined); const user = setupUser(); - const select = await screen.findByLabelText('Memory workspace'); - await user.click(select); - await screen.findByRole('option', { name: 'Memory One' }); - await waitFor(() => { - expect(screen.getByRole('option', { name: 'Memory One' })).toHaveAttribute('data-state', 'checked'); - }); - await user.click(await screen.findByRole('option', { name: 'Memory Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); - const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'memoryToolMemory'); - expect(relation).toMatchObject({ selections: ['memory-2'] }); - }); + render( + + + , + ); - it('updates memory connector memory relation', async () => { - const graphNodes = [ - createGraphNode({ id: 'memory-connector-1', template: 'memoryConnector', kind: 'Workspace', title: 'Memory Connector' }), - createGraphNode({ id: 'memory-1', template: 'memory', kind: 'Workspace', title: 'Memory One' }), - createGraphNode({ id: 'memory-2', template: 'memory', kind: 'Workspace', title: 'Memory Two' }), - ]; - const graphEdges = [ - createGraphEdge({ source: 'memory-1', sourceHandle: '$self', target: 'memory-connector-1', targetHandle: '$memory' }), - ]; - const entity = createEntitySummary({ - id: 'memory-connector-1', - templateName: 'memoryConnector', - templateKind: 'workspace', - rawTemplateKind: 'workspace', - title: 'Memory Connector', - }); - const { onSubmit } = renderRelationDialog({ - kind: 'workspace', - templateName: 'memoryConnector', - graphNodes, - graphEdges, - entity, - }); - const user = setupUser(); + const submitButton = await screen.findByRole('button', { name: /save changes/i }); + await user.click(submitButton); - const select = await screen.findByLabelText('Memory workspace'); - await user.click(select); - await screen.findByRole('option', { name: 'Memory One' }); await waitFor(() => { - expect(screen.getByRole('option', { name: 'Memory One' })).toHaveAttribute('data-state', 'checked'); + expect(onSubmit).toHaveBeenCalledTimes(1); }); - await user.click(await screen.findByRole('option', { name: 'Memory Two' })); - await user.click(screen.getByRole('button', { name: /save changes/i })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); const payload = onSubmit.mock.calls[0][0]; - const relation = payload.relations?.find((item) => item.id === 'memoryConnectorMemory'); - expect(relation).toMatchObject({ selections: ['memory-2'] }); + const relations = payload.relations ?? []; + expect(relations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'mcpServerWorkspace', + selections: ['workspace-1'], + attachmentKind: TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration, + }), + ]), + ); }); }); diff --git a/packages/platform-ui/src/components/nodeProperties/views/WorkspaceMemoryTemplateView.tsx b/packages/platform-ui/src/components/nodeProperties/views/WorkspaceMemoryTemplateView.tsx index b7b238918..c7776262b 100644 --- a/packages/platform-ui/src/components/nodeProperties/views/WorkspaceMemoryTemplateView.tsx +++ b/packages/platform-ui/src/components/nodeProperties/views/WorkspaceMemoryTemplateView.tsx @@ -1,6 +1,7 @@ -import { useCallback } from 'react'; +import { useCallback, type ChangeEvent } from 'react'; import { Dropdown } from '../../Dropdown'; +import { Input } from '../../Input'; import { FieldLabel } from '../FieldLabel'; import type { NodePropertiesViewProps } from '../viewTypes'; import { isRecord } from '../utils'; @@ -20,6 +21,12 @@ export function MemoryWorkspaceTemplateView({ config, onConfigChange }: MemoryTe : undefined; const rawScope = typeof configRecord.scope === 'string' ? (configRecord.scope as string) : undefined; const staticScope = typeof staticConfig?.scope === 'string' ? (staticConfig.scope as string) : undefined; + const rawCollectionPrefix = + typeof configRecord.collectionPrefix === 'string' + ? (configRecord.collectionPrefix as string) + : typeof configRecord.collection_prefix === 'string' + ? (configRecord.collection_prefix as string) + : ''; const scope: MemoryScopeOption = rawScope === 'perThread' ? 'perThread' @@ -37,6 +44,14 @@ export function MemoryWorkspaceTemplateView({ config, onConfigChange }: MemoryTe [onConfigChange], ); + const handleCollectionPrefixChange = useCallback( + (event: ChangeEvent) => { + const value = event.target.value; + onConfigChange?.({ collectionPrefix: value.trim().length > 0 ? value : undefined }); + }, + [onConfigChange], + ); + return (
@@ -51,6 +66,17 @@ export function MemoryWorkspaceTemplateView({ config, onConfigChange }: MemoryTe options={SCOPE_OPTIONS} />
+
+ + +
); } diff --git a/packages/platform-ui/src/features/entities/api/graphEntities.ts b/packages/platform-ui/src/features/entities/api/graphEntities.ts deleted file mode 100644 index 1eb99baca..000000000 --- a/packages/platform-ui/src/features/entities/api/graphEntities.ts +++ /dev/null @@ -1,418 +0,0 @@ -import type { PersistedGraph } from '@agyn/shared'; -import type { PersistedGraphUpsertRequestUI } from '@/api/modules/graph'; -import type { TemplateSchema } from '@/api/types/graph'; -import { getUuid } from '@/utils/getUuid'; -import { - type EntityPortDefinition, - type GraphEdgeFilter, - type GraphEntityDeleteInput, - type GraphEntityEdge, - type GraphEntityGraph, - type GraphEntityKind, - type GraphEntityRelationEdge, - type GraphEntityRelationInput, - type GraphEntitySummary, - type GraphEntityUpsertInput, - type TemplateOption, -} from '../types'; - -export const EXCLUDED_WORKSPACE_TEMPLATES = new Set(['memory', 'memoryConnector']); -export const INCLUDED_MEMORY_WORKSPACE_TEMPLATES = new Set(['memory', 'memoryConnector']); - -function buildEdgeId(source: string, sourceHandle: string, target: string, targetHandle: string): string { - const normalizedSourceHandle = sourceHandle?.length ? sourceHandle : '$self'; - const normalizedTargetHandle = targetHandle?.length ? targetHandle : '$self'; - return `${source}-${normalizedSourceHandle}__${target}-${normalizedTargetHandle}`; -} - -function matchesEdgeFilter(edge: GraphEntityEdge, filter: GraphEdgeFilter): boolean { - if (filter.sourceId && edge.source !== filter.sourceId) return false; - if (filter.sourceHandle && edge.sourceHandle !== filter.sourceHandle) return false; - if (filter.targetId && edge.target !== filter.targetId) return false; - if (filter.targetHandle && edge.targetHandle !== filter.targetHandle) return false; - return true; -} - -export function listTargetsByEdge(edges: GraphEntityEdge[] | undefined, filter: GraphEdgeFilter): GraphEntityEdge[] { - if (!Array.isArray(edges) || edges.length === 0) { - return []; - } - return edges.filter((edge): edge is GraphEntityEdge => Boolean(edge) && matchesEdgeFilter(edge, filter)); -} - -type ReplaceEdgesOptions = GraphEdgeFilter & { - edges: PersistedGraph['edges'] | undefined; - nextPairs: GraphEntityRelationEdge[]; -}; - -export function replaceEdgesForHandle(options: ReplaceEdgesOptions): PersistedGraph['edges'] { - const { edges, nextPairs, ...filter } = options; - const baseEdges = Array.isArray(edges) - ? edges.filter((edge): edge is PersistedGraph['edges'][number] => Boolean(edge)) - : []; - const remaining = baseEdges.filter((edge) => !matchesEdgeFilter(edge, filter)); - - const merged = new Map(); - for (const edge of remaining) { - const key = edge.id ?? buildEdgeId(edge.source, edge.sourceHandle, edge.target, edge.targetHandle); - merged.set(key, edge); - } - - for (const pair of nextPairs) { - if (!pair.sourceId || !pair.targetId) continue; - const normalizedSourceHandle = pair.sourceHandle?.length ? pair.sourceHandle : '$self'; - const normalizedTargetHandle = pair.targetHandle?.length ? pair.targetHandle : '$self'; - const id = buildEdgeId(pair.sourceId, normalizedSourceHandle, pair.targetId, normalizedTargetHandle); - merged.set(id, { - id, - source: pair.sourceId, - sourceHandle: normalizedSourceHandle, - target: pair.targetId, - targetHandle: normalizedTargetHandle, - }); - } - - return Array.from(merged.values()); -} - -function ensureRecord(value: unknown): Record { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Record; - } - return {}; -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isEnvEntryRecord(value: Record): boolean { - return typeof value.name === 'string' && Object.prototype.hasOwnProperty.call(value, 'value'); -} - -function sanitizeEnvEntry(entry: Record): Record { - const next: Record = {}; - for (const [key, nested] of Object.entries(entry)) { - if (key === 'source') { - continue; - } - next[key] = sanitizeConfigValue(nested); - } - return next; -} - -function sanitizeConfigValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => { - if (isPlainRecord(item) && isEnvEntryRecord(item)) { - return sanitizeEnvEntry(item); - } - return sanitizeConfigValue(item); - }); - } - if (isPlainRecord(value)) { - if (isEnvEntryRecord(value)) { - return sanitizeEnvEntry(value); - } - const next: Record = {}; - for (const [key, nested] of Object.entries(value)) { - next[key] = sanitizeConfigValue(nested); - } - return next; - } - return value; -} - -export function sanitizeConfigForPersistence(_templateName: string, config: Record | undefined): Record { - const base = ensureRecord(config ?? {}); - const sanitized: Record = {}; - for (const [key, value] of Object.entries(base)) { - if (key === 'title' || key === 'template' || key === 'kind') { - continue; - } - if (value === undefined) { - continue; - } - sanitized[key] = sanitizeConfigValue(value); - } - return sanitized; -} - -function cloneGraph(graph: PersistedGraph): PersistedGraph { - if (typeof structuredClone === 'function') { - return structuredClone(graph); - } - return JSON.parse(JSON.stringify(graph)) as PersistedGraph; -} - -export function resolveEntityKind(rawKind?: string | null): GraphEntityKind { - switch (rawKind) { - case 'trigger': - return 'trigger'; - case 'agent': - return 'agent'; - case 'tool': - return 'tool'; - case 'mcp': - return 'mcp'; - case 'service': - default: - return 'workspace'; - } -} - -function deriveNodeTitle(node: { template: string; config?: Record }, template?: TemplateSchema): string { - const configTitle = node?.config && typeof node.config === 'object' ? (node.config as Record).title : undefined; - if (typeof configTitle === 'string' && configTitle.trim().length > 0) { - return configTitle.trim(); - } - if (template?.title) { - return template.title; - } - return node.template; -} - -function normalizePortLabel(portId: string, definition: unknown): string { - if (typeof definition === 'string' && definition.trim().length > 0) { - return definition.trim(); - } - if (definition && typeof definition === 'object') { - const record = definition as Record; - const label = record.title ?? record.label ?? record.name; - if (typeof label === 'string' && label.trim().length > 0) { - return label.trim(); - } - } - return portId; -} - -function toPortList(portDefinition: TemplateSchema['sourcePorts']): EntityPortDefinition[] { - if (!portDefinition) return []; - if (Array.isArray(portDefinition)) { - return portDefinition - .filter((port): port is string => typeof port === 'string' && port.trim().length > 0) - .map((port) => ({ id: port, label: port })); - } - if (typeof portDefinition === 'object') { - return Object.entries(portDefinition) - .filter(([key]) => typeof key === 'string' && key.trim().length > 0) - .map(([key, definition]) => ({ id: key, label: normalizePortLabel(key, definition) })); - } - return []; -} - -export function getTemplatePorts(template?: TemplateSchema): { inputs: EntityPortDefinition[]; outputs: EntityPortDefinition[] } { - if (!template) { - return { inputs: [], outputs: [] }; - } - return { - inputs: toPortList(template.targetPorts), - outputs: toPortList(template.sourcePorts), - }; -} - -export function mapGraphEntities(graph: GraphEntityGraph | undefined, templates: TemplateSchema[] = []): GraphEntitySummary[] { - if (!graph) return []; - const templateByName = new Map(); - for (const template of templates) { - if (template?.name) { - templateByName.set(template.name, template); - } - } - - const edges = Array.isArray(graph.edges) ? graph.edges : []; - const incoming = new Map(); - const outgoing = new Map(); - for (const edge of edges) { - if (!edge) continue; - incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1); - outgoing.set(edge.source, (outgoing.get(edge.source) ?? 0) + 1); - } - - const summaries: GraphEntitySummary[] = []; - for (const node of graph.nodes ?? []) { - if (!node) continue; - const template = templateByName.get(node.template); - const config = ensureRecord(node.config); - const portGroup = getTemplatePorts(template); - const resolvedKind = resolveEntityKind(template?.kind); - - summaries.push({ - id: node.id, - node, - title: deriveNodeTitle(node, template), - templateName: node.template, - templateTitle: template?.title ?? node.template, - templateKind: resolvedKind, - rawTemplateKind: template?.kind, - config, - state: node.state ? { ...(node.state as Record) } : undefined, - position: node.position ? { ...node.position } : undefined, - ports: portGroup, - relations: { - incoming: incoming.get(node.id) ?? 0, - outgoing: outgoing.get(node.id) ?? 0, - }, - }); - } - - return summaries; -} - -export function getTemplateOptions( - templates: TemplateSchema[] = [], - kind?: GraphEntityKind, - excludeTemplateNames?: ReadonlySet | Set, -): TemplateOption[] { - return templates - .map((template) => ({ - name: template.name, - title: template.title ?? template.name, - kind: resolveEntityKind(template.kind), - source: template, - })) - .filter((option) => { - if (kind && option.kind !== kind) { - return false; - } - if (excludeTemplateNames && excludeTemplateNames.has(option.name)) { - return false; - } - return true; - }) - .sort((a, b) => a.title.localeCompare(b.title)); -} - - -function buildGraphPayloadInternal(graph: PersistedGraph): PersistedGraphUpsertRequestUI { - const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; - const edges = Array.isArray(graph.edges) ? graph.edges : []; - return { - name: graph.name, - version: graph.version, - nodes, - edges, - } satisfies PersistedGraphUpsertRequestUI; -} - -export function buildGraphPayload(graph: PersistedGraph): PersistedGraphUpsertRequestUI { - return buildGraphPayloadInternal(graph); -} - -function generateNodeId(graph: PersistedGraph): string { - const existing = new Set((graph.nodes ?? []).map((node) => node.id)); - let candidate = getUuid(); - while (existing.has(candidate)) { - candidate = getUuid(); - } - return candidate; -} - -function sanitizeConfig(value: Record, title: string): Record { - const base = ensureRecord(value ?? {}); - return { ...base, title }; -} - -function applyRelationEdges( - existingEdges: PersistedGraph['edges'] | undefined, - nodeId: string, - relations: GraphEntityRelationInput[] | undefined, -): PersistedGraph['edges'] { - if (!relations || relations.length === 0) { - return Array.isArray(existingEdges) - ? existingEdges.filter((edge): edge is PersistedGraph['edges'][number] => Boolean(edge)) - : []; - } - - let nextEdges = Array.isArray(existingEdges) - ? existingEdges.filter((edge): edge is PersistedGraph['edges'][number] => Boolean(edge)) - : []; - - for (const relation of relations) { - const ownerId = relation.ownerId && relation.ownerId.length > 0 ? relation.ownerId : nodeId; - if (!ownerId) continue; - const normalizedSelections = relation.mode === 'single' - ? relation.selections.slice(0, 1) - : relation.selections; - const peerIds = Array.from(new Set(normalizedSelections.filter((value) => typeof value === 'string' && value.length > 0))); - - const filter: GraphEdgeFilter = - relation.ownerRole === 'source' - ? { sourceId: ownerId, sourceHandle: relation.ownerHandle } - : { targetId: ownerId, targetHandle: relation.ownerHandle }; - - const nextPairs: GraphEntityRelationEdge[] = peerIds.map((peerId) => - relation.ownerRole === 'source' - ? { - sourceId: ownerId, - sourceHandle: relation.ownerHandle, - targetId: peerId, - targetHandle: relation.peerHandle, - } - : { - sourceId: peerId, - sourceHandle: relation.peerHandle, - targetId: ownerId, - targetHandle: relation.ownerHandle, - }, - ); - - nextEdges = replaceEdgesForHandle({ edges: nextEdges, ...filter, nextPairs }); - } - - return nextEdges; -} - -export function applyCreateEntity(graph: PersistedGraph, input: GraphEntityUpsertInput): PersistedGraph { - const base = cloneGraph(graph); - const nodeId = input.id && input.id.trim().length > 0 ? input.id.trim() : generateNodeId(base); - const nodes = Array.isArray(base.nodes) ? [...base.nodes] : []; - const config = sanitizeConfig(input.config, input.title); - const newNode = { - id: nodeId, - template: input.template, - config, - state: undefined, - position: { x: 0, y: 0 }, - } satisfies PersistedGraph['nodes'][number]; - nodes.push(newNode); - return { - ...base, - nodes, - edges: applyRelationEdges(base.edges, nodeId, input.relations), - } satisfies PersistedGraph; -} - -export function applyUpdateEntity(graph: PersistedGraph, input: GraphEntityUpsertInput): PersistedGraph { - if (!input.id) { - throw new Error('Entity id is required for updates'); - } - const base = cloneGraph(graph); - const nodes = Array.isArray(base.nodes) ? [...base.nodes] : []; - const index = nodes.findIndex((node) => node?.id === input.id); - if (index === -1) { - throw new Error(`Node ${input.id} not found`); - } - const existing = nodes[index]; - const config = sanitizeConfig(input.config, input.title); - nodes[index] = { - ...existing, - config, - }; - return { - ...base, - nodes, - edges: applyRelationEdges(base.edges, input.id, input.relations), - } satisfies PersistedGraph; -} - -export function applyDeleteEntity(graph: PersistedGraph, input: GraphEntityDeleteInput): PersistedGraph { - const base = cloneGraph(graph); - const nodes = (base.nodes ?? []).filter((node) => node?.id !== input.id); - const edges = (base.edges ?? []).filter((edge) => edge?.source !== input.id && edge?.target !== input.id); - return { - ...base, - nodes, - edges, - } satisfies PersistedGraph; -} diff --git a/packages/platform-ui/src/features/entities/api/teamEntities.ts b/packages/platform-ui/src/features/entities/api/teamEntities.ts new file mode 100644 index 000000000..563272a4c --- /dev/null +++ b/packages/platform-ui/src/features/entities/api/teamEntities.ts @@ -0,0 +1,1105 @@ +import type { TemplateSchema } from '@/api/types/graph'; +import type { + TeamAgent, + TeamAttachment, + TeamAttachmentKind, + TeamMemoryBucket, + TeamMemoryBucketScope, + TeamMcpServer, + TeamTool, + TeamToolType, + TeamWorkspaceConfiguration, + TeamWorkspacePlatform, +} from '@/api/types/team'; +import type { AgentQueueConfig, NodeConfig } from '@/components/nodeProperties/types'; +import { readEnvList, readQueueConfig, readSummarizationConfig } from '@/components/nodeProperties/utils'; +import { buildGraphNodeFromTemplate } from '@/features/graph/mappers'; +import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; +import type { + GraphEdgeFilter, + GraphEntityKind, + GraphEntityRelationInput, + GraphEntitySummary, + GraphEntityUpsertInput, + TemplateOption, +} from '../types'; + +export const EXCLUDED_WORKSPACE_TEMPLATES = new Set(['memory', 'memoryConnector']); +export const INCLUDED_MEMORY_TEMPLATES = new Set(['memory']); + +export const TEAM_ATTACHMENT_KIND = { + agentTool: 'ATTACHMENT_KIND_AGENT_TOOL', + agentMemoryBucket: 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET', + agentWorkspaceConfiguration: 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION', + agentMcpServer: 'ATTACHMENT_KIND_AGENT_MCP_SERVER', + mcpServerWorkspaceConfiguration: 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION', +} as const satisfies Record; + +export const TEAM_TOOL_TYPE = { + manage: 'TOOL_TYPE_MANAGE', + memory: 'TOOL_TYPE_MEMORY', + shellCommand: 'TOOL_TYPE_SHELL_COMMAND', + sendMessage: 'TOOL_TYPE_SEND_MESSAGE', + sendSlackMessage: 'TOOL_TYPE_SEND_SLACK_MESSAGE', + remindMe: 'TOOL_TYPE_REMIND_ME', + githubCloneRepo: 'TOOL_TYPE_GITHUB_CLONE_REPO', + callAgent: 'TOOL_TYPE_CALL_AGENT', +} as const satisfies Record; + +const TOOL_TYPE_TO_TEMPLATE: Record = { + TOOL_TYPE_UNSPECIFIED: 'tool', + TOOL_TYPE_MANAGE: 'manageTool', + TOOL_TYPE_MEMORY: 'memoryTool', + TOOL_TYPE_SHELL_COMMAND: 'shellTool', + TOOL_TYPE_SEND_MESSAGE: 'sendMessageTool', + TOOL_TYPE_SEND_SLACK_MESSAGE: 'sendSlackMessageTool', + TOOL_TYPE_REMIND_ME: 'remindMeTool', + TOOL_TYPE_GITHUB_CLONE_REPO: 'githubCloneRepoTool', + TOOL_TYPE_CALL_AGENT: 'callAgentTool', +}; + +const TEMPLATE_TO_TOOL_TYPE: Record = Object.entries(TOOL_TYPE_TO_TEMPLATE).reduce( + (acc, [toolType, templateName]) => { + acc[templateName] = toolType as TeamToolType; + return acc; + }, + {} as Record, +); + +const ENTITY_KIND_TO_NODE_KIND: Record = { + agent: 'Agent', + tool: 'Tool', + mcp: 'MCP', + workspace: 'Workspace', + memory: 'Workspace', + trigger: 'Trigger', +}; + +const ATTACHMENT_KIND_HANDLES: Record = { + ATTACHMENT_KIND_UNSPECIFIED: { sourceHandle: '$self', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_TOOL: { sourceHandle: 'tools', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_MEMORY_BUCKET: { sourceHandle: 'memory', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_MCP_SERVER: { sourceHandle: 'mcp', targetHandle: '$self' }, + ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, +}; + +export type TeamAttachmentInput = { + kind: TeamAttachmentKind; + sourceId: string; + targetId: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + return undefined; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + return undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value.toLowerCase() === 'true') return true; + if (value.toLowerCase() === 'false') return false; + } + return undefined; +} + +function readField(record: Record, keys: string[], reader: (value: unknown) => T | undefined): T | undefined { + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + const value = reader(record[key]); + if (value !== undefined) return value; + } + return undefined; +} + +function normalizeEnumName(value: unknown): string { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'string') { + return value.trim().toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); + } + return ''; +} + +function normalizeTeamToolType(value: unknown): TeamToolType | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return TEAM_TOOL_TYPE.manage; + case 2: + return TEAM_TOOL_TYPE.memory; + case 3: + return TEAM_TOOL_TYPE.shellCommand; + case 4: + return TEAM_TOOL_TYPE.sendMessage; + case 5: + return TEAM_TOOL_TYPE.sendSlackMessage; + case 6: + return TEAM_TOOL_TYPE.remindMe; + case 7: + return TEAM_TOOL_TYPE.githubCloneRepo; + case 8: + return TEAM_TOOL_TYPE.callAgent; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('TOOL_TYPE_')) { + return normalized as TeamToolType; + } + switch (normalized) { + case 'MANAGE': + return TEAM_TOOL_TYPE.manage; + case 'MEMORY': + return TEAM_TOOL_TYPE.memory; + case 'SHELL_COMMAND': + case 'SHELL': + return TEAM_TOOL_TYPE.shellCommand; + case 'SEND_MESSAGE': + return TEAM_TOOL_TYPE.sendMessage; + case 'SEND_SLACK_MESSAGE': + return TEAM_TOOL_TYPE.sendSlackMessage; + case 'REMIND_ME': + return TEAM_TOOL_TYPE.remindMe; + case 'GITHUB_CLONE_REPO': + return TEAM_TOOL_TYPE.githubCloneRepo; + case 'CALL_AGENT': + return TEAM_TOOL_TYPE.callAgent; + default: + return undefined; + } +} + +function normalizeAttachmentKind(value: unknown): TeamAttachmentKind | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return TEAM_ATTACHMENT_KIND.agentTool; + case 2: + return TEAM_ATTACHMENT_KIND.agentMemoryBucket; + case 3: + return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; + case 4: + return TEAM_ATTACHMENT_KIND.agentMcpServer; + case 5: + return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('ATTACHMENT_KIND_')) { + if (normalized === 'ATTACHMENT_KIND_UNSPECIFIED') return undefined; + return normalized as TeamAttachmentKind; + } + switch (normalized) { + case 'AGENT_TOOL': + return TEAM_ATTACHMENT_KIND.agentTool; + case 'AGENT_MEMORY_BUCKET': + return TEAM_ATTACHMENT_KIND.agentMemoryBucket; + case 'AGENT_WORKSPACE_CONFIGURATION': + return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; + case 'AGENT_MCP_SERVER': + return TEAM_ATTACHMENT_KIND.agentMcpServer; + case 'MCP_SERVER_WORKSPACE_CONFIGURATION': + return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; + default: + return undefined; + } +} + +function normalizeWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return 'WORKSPACE_PLATFORM_LINUX_AMD64'; + case 2: + return 'WORKSPACE_PLATFORM_LINUX_ARM64'; + case 3: + return 'WORKSPACE_PLATFORM_AUTO'; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('WORKSPACE_PLATFORM_')) { + return normalized as TeamWorkspacePlatform; + } + switch (normalized) { + case 'LINUX_AMD64': + case 'LINUX_AMD_64': + case 'LINUX/AMD64': + return 'WORKSPACE_PLATFORM_LINUX_AMD64'; + case 'LINUX_ARM64': + case 'LINUX_ARM_64': + case 'LINUX/ARM64': + return 'WORKSPACE_PLATFORM_LINUX_ARM64'; + case 'AUTO': + return 'WORKSPACE_PLATFORM_AUTO'; + default: + return undefined; + } +} + +function normalizeMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return 'MEMORY_BUCKET_SCOPE_GLOBAL'; + case 2: + return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('MEMORY_BUCKET_SCOPE_')) { + return normalized as TeamMemoryBucketScope; + } + switch (normalized) { + case 'GLOBAL': + return 'MEMORY_BUCKET_SCOPE_GLOBAL'; + case 'PER_THREAD': + case 'PERTHREAD': + return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + default: + return undefined; + } +} + +type AgentQueueWhenBusy = NonNullable; +type AgentQueueProcessBuffer = NonNullable; + +function parseWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { + if (typeof value === 'number') { + if (value === 1) return 'wait'; + if (value === 2) return 'injectAfterTools'; + } + const normalized = normalizeEnumName(value); + if (normalized.includes('WAIT')) return 'wait'; + if (normalized.includes('INJECT_AFTER_TOOLS')) return 'injectAfterTools'; + return undefined; +} + +function parseProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { + if (typeof value === 'number') { + if (value === 1) return 'allTogether'; + if (value === 2) return 'oneByOne'; + } + const normalized = normalizeEnumName(value); + if (normalized.includes('ALL_TOGETHER')) return 'allTogether'; + if (normalized.includes('ONE_BY_ONE')) return 'oneByOne'; + return undefined; +} + +function mapWorkspacePlatformToUi(value: unknown): string | undefined { + const normalized = normalizeWorkspacePlatform(value); + switch (normalized) { + case 'WORKSPACE_PLATFORM_LINUX_AMD64': + return 'linux/amd64'; + case 'WORKSPACE_PLATFORM_LINUX_ARM64': + return 'linux/arm64'; + case 'WORKSPACE_PLATFORM_AUTO': + return 'auto'; + default: + return undefined; + } +} + +function mapWorkspacePlatformToTeam(value: unknown): TeamWorkspacePlatform | undefined { + if (typeof value === 'string') { + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'linux/amd64') return 'WORKSPACE_PLATFORM_LINUX_AMD64'; + if (trimmed === 'linux/arm64') return 'WORKSPACE_PLATFORM_LINUX_ARM64'; + if (trimmed === 'auto') return 'WORKSPACE_PLATFORM_AUTO'; + } + return normalizeWorkspacePlatform(value); +} + +function mapMemoryScopeToUi(value: unknown): string | undefined { + const normalized = normalizeMemoryScope(value); + if (normalized === 'MEMORY_BUCKET_SCOPE_GLOBAL') return 'global'; + if (normalized === 'MEMORY_BUCKET_SCOPE_PER_THREAD') return 'perThread'; + return undefined; +} + +function mapMemoryScopeToTeam(value: unknown): TeamMemoryBucketScope | undefined { + if (typeof value === 'string') { + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'global') return 'MEMORY_BUCKET_SCOPE_GLOBAL'; + if (trimmed === 'perthread' || trimmed === 'per_thread') return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + } + return normalizeMemoryScope(value); +} + +function mapWhenBusyToTeam(value: unknown): string | undefined { + if (value === 'wait') return 'AGENT_WHEN_BUSY_WAIT'; + if (value === 'injectAfterTools') return 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; + return undefined; +} + +function mapProcessBufferToTeam(value: unknown): string | undefined { + if (value === 'allTogether') return 'AGENT_PROCESS_BUFFER_ALL_TOGETHER'; + if (value === 'oneByOne') return 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; + return undefined; +} + +function mapAgentConfigFromTeam(raw: Record): Record { + const config: Record = {}; + const model = readField(raw, ['model'], readString); + if (model) config.model = model; + const systemPrompt = readField(raw, ['systemPrompt', 'system_prompt'], readString); + if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; + const debounceMs = readField(raw, ['debounceMs', 'debounce_ms'], readNumber); + const whenBusy = parseWhenBusy(readField(raw, ['whenBusy', 'when_busy'], (value) => value)); + const processBuffer = parseProcessBuffer(readField(raw, ['processBuffer', 'process_buffer'], (value) => value)); + if (debounceMs !== undefined || whenBusy || processBuffer) { + const queue: Record = {}; + if (debounceMs !== undefined) queue.debounceMs = debounceMs; + if (whenBusy) queue.whenBusy = whenBusy; + if (processBuffer) queue.processBuffer = processBuffer; + config.queue = queue; + } + const sendFinalResponseToThread = readField( + raw, + ['sendFinalResponseToThread', 'send_final_response_to_thread'], + readBoolean, + ); + if (sendFinalResponseToThread !== undefined) { + config.sendFinalResponseToThread = sendFinalResponseToThread; + } + const summarizationKeepTokens = readField( + raw, + ['summarizationKeepTokens', 'summarization_keep_tokens'], + readNumber, + ); + const summarizationMaxTokens = readField( + raw, + ['summarizationMaxTokens', 'summarization_max_tokens'], + readNumber, + ); + if (summarizationKeepTokens !== undefined || summarizationMaxTokens !== undefined) { + const summarization: Record = {}; + if (summarizationKeepTokens !== undefined) summarization.keepTokens = summarizationKeepTokens; + if (summarizationMaxTokens !== undefined) summarization.maxTokens = summarizationMaxTokens; + config.summarization = summarization; + } + const restrictOutput = readField(raw, ['restrictOutput', 'restrict_output'], readBoolean); + if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; + const restrictionMessage = readField(raw, ['restrictionMessage', 'restriction_message'], readString); + if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; + const restrictionMaxInjections = readField( + raw, + ['restrictionMaxInjections', 'restriction_max_injections'], + readNumber, + ); + if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; + const name = readField(raw, ['name'], readString); + if (name) config.name = name; + const role = readField(raw, ['role'], readString); + if (role) config.role = role; + return config; +} + +function mapToolConfigFromTeam(raw: Record, tool: TeamTool): Record { + const config = { ...raw }; + const toolName = readString(tool.name ?? config.name); + if (toolName && typeof config.name !== 'string') { + config.name = toolName; + } + return config; +} + +function mapMcpConfigFromTeam(raw: Record): Record { + const config: Record = {}; + const namespace = readField(raw, ['namespace'], readString); + if (namespace) config.namespace = namespace; + const command = readField(raw, ['command'], readString); + if (command) config.command = command; + const workdir = readField(raw, ['workdir'], readString); + if (workdir) config.workdir = workdir; + const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); + if (env) config.env = env; + const requestTimeoutMs = readField(raw, ['requestTimeoutMs', 'request_timeout_ms'], readNumber); + if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; + const startupTimeoutMs = readField(raw, ['startupTimeoutMs', 'startup_timeout_ms'], readNumber); + if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; + const heartbeatIntervalMs = readField(raw, ['heartbeatIntervalMs', 'heartbeat_interval_ms'], readNumber); + if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; + const staleTimeoutMs = readField(raw, ['staleTimeoutMs', 'stale_timeout_ms'], readNumber); + if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; + const restart = readField(raw, ['restart'], (value) => (isRecord(value) ? value : undefined)); + if (restart) { + const restartConfig: Record = {}; + const maxAttempts = readField(restart, ['maxAttempts', 'max_attempts'], readNumber); + const backoffMs = readField(restart, ['backoffMs', 'backoff_ms'], readNumber); + if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; + if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; + config.restart = restartConfig; + } + return config; +} + +function mapWorkspaceConfigFromTeam(raw: Record): Record { + const config: Record = {}; + const image = readField(raw, ['image'], readString); + if (image) config.image = image; + const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); + if (env) config.env = env; + const initialScript = readField(raw, ['initialScript', 'initial_script'], readOptionalString); + if (initialScript !== undefined) config.initialScript = initialScript; + const cpuLimit = readField(raw, ['cpu_limit', 'cpuLimit'], (value) => value as unknown); + if (cpuLimit !== undefined) config.cpu_limit = cpuLimit; + const memoryLimit = readField(raw, ['memory_limit', 'memoryLimit'], (value) => value as unknown); + if (memoryLimit !== undefined) config.memory_limit = memoryLimit; + const platform = readField(raw, ['platform'], (value) => value as unknown); + const platformValue = mapWorkspacePlatformToUi(platform); + if (platformValue) config.platform = platformValue; + const enableDinD = readField(raw, ['enableDinD', 'enable_dind', 'enableDind'], readBoolean); + if (enableDinD !== undefined) config.enableDinD = enableDinD; + const ttlSeconds = readField(raw, ['ttlSeconds', 'ttl_seconds'], readNumber); + if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; + const nix = readField(raw, ['nix'], (value) => (isRecord(value) ? value : undefined)); + if (nix) config.nix = nix; + const volumes = readField(raw, ['volumes'], (value) => (isRecord(value) ? value : undefined)); + if (volumes) { + const volumeConfig: Record = {}; + const enabled = readField(volumes, ['enabled'], readBoolean); + if (enabled !== undefined) volumeConfig.enabled = enabled; + const mountPath = readField(volumes, ['mountPath', 'mount_path'], readOptionalString); + if (mountPath !== undefined) volumeConfig.mountPath = mountPath; + config.volumes = volumeConfig; + } + return config; +} + +function mapMemoryBucketConfigFromTeam(raw: Record): Record { + const config: Record = {}; + const scope = readField(raw, ['scope'], (value) => value as unknown); + const scopeValue = mapMemoryScopeToUi(scope); + if (scopeValue) config.scope = scopeValue; + const collectionPrefix = readField(raw, ['collectionPrefix', 'collection_prefix'], readOptionalString); + if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; + return config; +} + +function resolveTemplateKind(rawKind?: string | null, templateName?: string): GraphEntityKind { + switch (rawKind) { + case 'trigger': + return 'trigger'; + case 'agent': + return 'agent'; + case 'tool': + return 'tool'; + case 'mcp': + return 'mcp'; + case 'service': + default: { + if (templateName === 'memory') return 'memory'; + return 'workspace'; + } + } +} + +function resolveTemplateEntityKind(template: TemplateSchema): GraphEntityKind { + return resolveTemplateKind(template.kind, template.name); +} + +function normalizeTemplateOptions( + templates: TemplateSchema[], + kind?: GraphEntityKind, + excludeTemplateNames?: ReadonlySet | Set, +): TemplateOption[] { + return templates + .map((template) => ({ + name: template.name, + title: template.title ?? template.name, + kind: resolveTemplateEntityKind(template), + source: template, + })) + .filter((option) => { + if (kind && option.kind !== kind) return false; + if (excludeTemplateNames && excludeTemplateNames.has(option.name)) return false; + return true; + }) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +export function getTemplateOptions( + templates: TemplateSchema[] = [], + kind?: GraphEntityKind, + excludeTemplateNames?: ReadonlySet | Set, +): TemplateOption[] { + return normalizeTemplateOptions(templates, kind, excludeTemplateNames); +} + +export function limitTemplateOptionsForKind(options: TemplateOption[], kind: GraphEntityKind): TemplateOption[] { + if (kind === 'tool') return options; + if (options.length <= 1) return options; + const preferredNames: string[] = []; + if (kind === 'agent') preferredNames.push('agent'); + if (kind === 'mcp') preferredNames.push('mcpServer', 'mcp'); + if (kind === 'workspace') preferredNames.push('workspace'); + if (kind === 'memory') preferredNames.push('memory'); + for (const name of preferredNames) { + const match = options.find((option) => option.name === name); + if (match) return [match]; + } + return options.slice(0, 1); +} + +function selectTemplate( + templates: TemplateSchema[], + kind: GraphEntityKind, + options: { includeNames?: ReadonlySet; excludeNames?: ReadonlySet; preferredNames?: string[] } = {}, +): TemplateSchema | undefined { + const candidates = templates.filter((template) => resolveTemplateEntityKind(template) === kind); + const filtered = candidates.filter((template) => { + if (options.includeNames && !options.includeNames.has(template.name)) return false; + if (options.excludeNames && options.excludeNames.has(template.name)) return false; + return true; + }); + if (options.preferredNames) { + for (const name of options.preferredNames) { + const match = filtered.find((template) => template.name === name); + if (match) return match; + } + } + return filtered[0]; +} + +function getTemplatePorts(template?: TemplateSchema): { inputs: Array<{ id: string; label: string }>; outputs: Array<{ id: string; label: string }> } { + if (!template) return { inputs: [], outputs: [] }; + const toPortList = (portDefinition: TemplateSchema['sourcePorts']): Array<{ id: string; label: string }> => { + if (!portDefinition) return []; + if (Array.isArray(portDefinition)) { + return portDefinition + .filter((port): port is string => typeof port === 'string' && port.trim().length > 0) + .map((port) => ({ id: port, label: port })); + } + if (typeof portDefinition === 'object') { + return Object.entries(portDefinition) + .filter(([key]) => typeof key === 'string' && key.trim().length > 0) + .map(([key, definition]) => { + if (typeof definition === 'string' && definition.trim().length > 0) { + return { id: key, label: definition.trim() }; + } + if (definition && typeof definition === 'object') { + const record = definition as Record; + const label = record.title ?? record.label ?? record.name; + if (typeof label === 'string' && label.trim().length > 0) { + return { id: key, label: label.trim() }; + } + } + return { id: key, label: key }; + }); + } + return []; + }; + + return { + inputs: toPortList(template.targetPorts), + outputs: toPortList(template.sourcePorts), + }; +} + +function resolveEntityTitle(candidate?: string): string { + if (candidate && candidate.trim().length > 0) return candidate.trim(); + return ''; +} + +export function mapTeamEntities( + sources: { + agents?: TeamAgent[]; + tools?: TeamTool[]; + mcpServers?: TeamMcpServer[]; + workspaceConfigurations?: TeamWorkspaceConfiguration[]; + memoryBuckets?: TeamMemoryBucket[]; + }, + templates: TemplateSchema[] = [], +): GraphEntitySummary[] { + const result: GraphEntitySummary[] = []; + + const addSummary = (summary: GraphEntitySummary | null) => { + if (summary) result.push(summary); + }; + + for (const agent of sources.agents ?? []) { + if (!agent) continue; + const id = readString(agent.id ?? agent.meta?.id); + if (!id) continue; + const template = selectTemplate(templates, 'agent', { preferredNames: ['agent'] }); + const templateName = template?.name ?? 'agent'; + const config = mapAgentConfigFromTeam(isRecord(agent.config) ? agent.config : {}); + const title = resolveEntityTitle(readString(agent.title) ?? template?.title ?? templateName) || templateName; + addSummary({ + id, + entityKind: 'agent', + title, + description: readString(agent.description), + templateName, + templateTitle: template?.title ?? templateName, + templateKind: resolveTemplateKind(template?.kind, templateName), + rawTemplateKind: template?.kind, + config, + ports: getTemplatePorts(template), + relations: { incoming: 0, outgoing: 0 }, + }); + } + + for (const tool of sources.tools ?? []) { + if (!tool) continue; + const id = readString(tool.id ?? tool.meta?.id); + if (!id) continue; + const toolType = normalizeTeamToolType(tool.type); + const templateName = (toolType && TOOL_TYPE_TO_TEMPLATE[toolType]) || 'tool'; + const template = templates.find((entry) => entry.name === templateName) ?? + selectTemplate(templates, 'tool'); + const config = mapToolConfigFromTeam(isRecord(tool.config) ? tool.config : {}, tool); + const titleCandidate = readString(tool.description) ?? readString(tool.name) ?? template?.title ?? templateName; + const title = resolveEntityTitle(titleCandidate) || templateName; + addSummary({ + id, + entityKind: 'tool', + title, + description: readString(tool.description), + templateName: template?.name ?? templateName, + templateTitle: template?.title ?? templateName, + templateKind: resolveTemplateKind(template?.kind, templateName), + rawTemplateKind: template?.kind, + config, + toolType, + toolName: readString(tool.name), + ports: getTemplatePorts(template), + relations: { incoming: 0, outgoing: 0 }, + }); + } + + for (const mcpServer of sources.mcpServers ?? []) { + if (!mcpServer) continue; + const id = readString(mcpServer.id ?? mcpServer.meta?.id); + if (!id) continue; + const template = selectTemplate(templates, 'mcp', { preferredNames: ['mcpServer', 'mcp'] }); + const templateName = template?.name ?? 'mcp'; + const config = mapMcpConfigFromTeam(isRecord(mcpServer.config) ? mcpServer.config : {}); + const titleCandidate = readString(mcpServer.title) ?? template?.title ?? templateName; + const title = resolveEntityTitle(titleCandidate) || templateName; + addSummary({ + id, + entityKind: 'mcp', + title, + description: readString(mcpServer.description), + templateName, + templateTitle: template?.title ?? templateName, + templateKind: resolveTemplateKind(template?.kind, templateName), + rawTemplateKind: template?.kind, + config, + ports: getTemplatePorts(template), + relations: { incoming: 0, outgoing: 0 }, + }); + } + + for (const workspace of sources.workspaceConfigurations ?? []) { + if (!workspace) continue; + const id = readString(workspace.id ?? workspace.meta?.id); + if (!id) continue; + const template = selectTemplate(templates, 'workspace', { + preferredNames: ['workspace'], + excludeNames: EXCLUDED_WORKSPACE_TEMPLATES, + }); + const templateName = template?.name ?? 'workspace'; + const config = mapWorkspaceConfigFromTeam(isRecord(workspace.config) ? workspace.config : {}); + const titleCandidate = readString(workspace.title) ?? template?.title ?? templateName; + const title = resolveEntityTitle(titleCandidate) || templateName; + addSummary({ + id, + entityKind: 'workspace', + title, + description: readString(workspace.description), + templateName, + templateTitle: template?.title ?? templateName, + templateKind: resolveTemplateKind(template?.kind, templateName), + rawTemplateKind: template?.kind, + config, + ports: getTemplatePorts(template), + relations: { incoming: 0, outgoing: 0 }, + }); + } + + for (const memory of sources.memoryBuckets ?? []) { + if (!memory) continue; + const id = readString(memory.id ?? memory.meta?.id); + if (!id) continue; + const template = selectTemplate(templates, 'memory', { + preferredNames: ['memory'], + includeNames: INCLUDED_MEMORY_TEMPLATES, + }); + const templateName = template?.name ?? 'memory'; + const config = mapMemoryBucketConfigFromTeam(isRecord(memory.config) ? memory.config : {}); + const titleCandidate = readString(memory.title) ?? template?.title ?? templateName; + const title = resolveEntityTitle(titleCandidate) || templateName; + addSummary({ + id, + entityKind: 'memory', + title, + description: readString(memory.description), + templateName, + templateTitle: template?.title ?? templateName, + templateKind: resolveTemplateKind(template?.kind, templateName), + rawTemplateKind: template?.kind, + config, + ports: getTemplatePorts(template), + relations: { incoming: 0, outgoing: 0 }, + }); + } + + return result; +} + +function buildEdgeId(source: string, sourceHandle: string, target: string, targetHandle: string): string { + const normalizedSourceHandle = sourceHandle?.length ? sourceHandle : '$self'; + const normalizedTargetHandle = targetHandle?.length ? targetHandle : '$self'; + return `${source}-${normalizedSourceHandle}__${target}-${normalizedTargetHandle}`; +} + +function matchesEdgeFilter(edge: GraphPersistedEdge, filter: GraphEdgeFilter): boolean { + if (filter.sourceId && edge.source !== filter.sourceId) return false; + if (filter.sourceHandle && edge.sourceHandle !== filter.sourceHandle) return false; + if (filter.targetId && edge.target !== filter.targetId) return false; + if (filter.targetHandle && edge.targetHandle !== filter.targetHandle) return false; + return true; +} + +export function listTargetsByEdge(edges: GraphPersistedEdge[] | undefined, filter: GraphEdgeFilter): GraphPersistedEdge[] { + if (!Array.isArray(edges) || edges.length === 0) { + return []; + } + return edges.filter((edge): edge is GraphPersistedEdge => Boolean(edge) && matchesEdgeFilter(edge, filter)); +} + +export function mapTeamAttachmentsToEdges(attachments: TeamAttachment[] | undefined): GraphPersistedEdge[] { + if (!Array.isArray(attachments)) return []; + const edges: GraphPersistedEdge[] = []; + for (const attachment of attachments) { + if (!attachment) continue; + const kind = normalizeAttachmentKind(attachment.kind); + if (!kind) continue; + const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); + const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); + if (!sourceId || !targetId) continue; + const handles = ATTACHMENT_KIND_HANDLES[kind]; + const id = buildEdgeId(sourceId, handles.sourceHandle, targetId, handles.targetHandle); + edges.push({ + id, + source: sourceId, + sourceHandle: handles.sourceHandle, + target: targetId, + targetHandle: handles.targetHandle, + }); + } + return edges; +} + +export function mapTeamEntitiesToGraphNodes( + entities: GraphEntitySummary[], + templates: TemplateSchema[] = [], +): GraphNodeConfig[] { + const templateByName = new Map(); + for (const template of templates) { + templateByName.set(template.name, template); + } + return entities.map((entity) => { + const template = templateByName.get(entity.templateName); + if (template) { + return buildGraphNodeFromTemplate(template, { + id: entity.id, + position: { x: 0, y: 0 }, + title: entity.title, + config: entity.config, + state: entity.state, + }).node; + } + return { + id: entity.id, + template: entity.templateName, + kind: ENTITY_KIND_TO_NODE_KIND[entity.templateKind], + title: entity.title, + x: 0, + y: 0, + status: 'ready', + config: entity.config, + state: entity.state, + ports: { inputs: [], outputs: [] }, + } satisfies GraphNodeConfig; + }); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isEnvEntryRecord(value: Record): boolean { + return typeof value.name === 'string' && Object.prototype.hasOwnProperty.call(value, 'value'); +} + +function sanitizeEnvEntry(entry: Record): Record { + const next: Record = {}; + for (const [key, nested] of Object.entries(entry)) { + if (key === 'source') { + continue; + } + next[key] = sanitizeConfigValue(nested); + } + return next; +} + +function sanitizeConfigValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => { + if (isPlainRecord(item) && isEnvEntryRecord(item)) { + return sanitizeEnvEntry(item); + } + return sanitizeConfigValue(item); + }); + } + if (isPlainRecord(value)) { + if (isEnvEntryRecord(value)) { + return sanitizeEnvEntry(value); + } + const next: Record = {}; + for (const [key, nested] of Object.entries(value)) { + next[key] = sanitizeConfigValue(nested); + } + return next; + } + return value; +} + +export function sanitizeConfigForPersistence(_templateName: string, config: Record | undefined): Record { + const base = isPlainRecord(config) ? config : {}; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(base)) { + if (key === 'title' || key === 'template' || key === 'kind') { + continue; + } + if (value === undefined) { + continue; + } + sanitized[key] = sanitizeConfigValue(value); + } + return sanitized; +} + +export function buildAttachmentInputsFromRelations(relations: GraphEntityRelationInput[] | undefined, ownerId?: string): TeamAttachmentInput[] { + if (!relations || relations.length === 0) return []; + const attachments: TeamAttachmentInput[] = []; + const seen = new Set(); + for (const relation of relations) { + if (!relation.attachmentKind) continue; + const resolvedOwnerId = relation.ownerId ?? ownerId; + if (!resolvedOwnerId) continue; + const selections = Array.isArray(relation.selections) ? relation.selections : []; + for (const selection of selections) { + if (!selection) continue; + const sourceId = relation.ownerRole === 'source' ? resolvedOwnerId : selection; + const targetId = relation.ownerRole === 'source' ? selection : resolvedOwnerId; + const key = `${relation.attachmentKind}:${sourceId}:${targetId}`; + if (seen.has(key)) continue; + seen.add(key); + attachments.push({ kind: relation.attachmentKind, sourceId, targetId }); + } + } + return attachments; +} + +export function diffTeamAttachments( + current: TeamAttachment[] | undefined, + desired: TeamAttachmentInput[], +): { create: TeamAttachmentInput[]; remove: TeamAttachment[] } { + const desiredKeys = new Set(); + for (const item of desired) { + desiredKeys.add(`${item.kind}:${item.sourceId}:${item.targetId}`); + } + + const normalizedCurrent: Array<{ key: string; attachment: TeamAttachment }> = []; + for (const attachment of current ?? []) { + const kind = normalizeAttachmentKind(attachment.kind); + if (!kind) continue; + const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); + const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); + if (!sourceId || !targetId) continue; + const key = `${kind}:${sourceId}:${targetId}`; + normalizedCurrent.push({ key, attachment: { ...attachment, kind } }); + } + + const currentKeys = new Set(normalizedCurrent.map((entry) => entry.key)); + const create = desired.filter((item) => !currentKeys.has(`${item.kind}:${item.sourceId}:${item.targetId}`)); + const remove = normalizedCurrent.filter((entry) => !desiredKeys.has(entry.key)).map((entry) => entry.attachment); + + return { create, remove }; +} + +function mapEnvListForTeam(env: unknown): Array<{ name: string; value: string }> { + const parsed = readEnvList(env); + return parsed + .map((item) => ({ name: item.name, value: item.value })) + .filter((item) => item.name.length > 0); +} + +export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { + const configRecord = input.config as Record; + const queue = readQueueConfig(configRecord as NodeConfig); + const summarization = readSummarizationConfig(configRecord as NodeConfig); + const payload: Record = { + title: input.title, + description: existing?.description ?? '', + config: {}, + }; + const config: Record = {}; + const model = readOptionalString(configRecord.model); + if (model !== undefined) config.model = model; + const systemPrompt = readOptionalString(configRecord.systemPrompt); + if (systemPrompt !== undefined) config.system_prompt = systemPrompt; + if (queue.debounceMs !== undefined) config.debounce_ms = queue.debounceMs; + if (queue.whenBusy) config.when_busy = mapWhenBusyToTeam(queue.whenBusy); + if (queue.processBuffer) config.process_buffer = mapProcessBufferToTeam(queue.processBuffer); + const sendFinal = readBoolean(configRecord.sendFinalResponseToThread); + if (sendFinal !== undefined) config.send_final_response_to_thread = sendFinal; + if (summarization.keepTokens !== undefined) config.summarization_keep_tokens = summarization.keepTokens; + if (summarization.maxTokens !== undefined) config.summarization_max_tokens = summarization.maxTokens; + const restrictOutput = readBoolean(configRecord.restrictOutput); + if (restrictOutput !== undefined) config.restrict_output = restrictOutput; + const restrictionMessage = readOptionalString(configRecord.restrictionMessage); + if (restrictionMessage !== undefined) config.restriction_message = restrictionMessage; + const restrictionMaxInjections = readNumber(configRecord.restrictionMaxInjections); + if (restrictionMaxInjections !== undefined) config.restriction_max_injections = restrictionMaxInjections; + const name = readOptionalString(configRecord.name); + if (name !== undefined) config.name = name; + const role = readOptionalString(configRecord.role); + if (role !== undefined) config.role = role; + payload.config = config; + return payload; +} + +export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { + const configRecord = input.config as Record; + const toolType = TEMPLATE_TO_TOOL_TYPE[input.template] ?? existing?.toolType ?? TEAM_TOOL_TYPE.manage; + const toolName = readOptionalString(configRecord.name) ?? existing?.toolName ?? input.title; + return { + type: toolType, + name: toolName, + description: input.title, + config: configRecord, + }; +} + +export function buildMcpServerRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { + const configRecord = input.config as Record; + const payload: Record = { + title: input.title, + description: existing?.description ?? '', + config: {}, + }; + const config: Record = {}; + const namespace = readOptionalString(configRecord.namespace); + if (namespace !== undefined) config.namespace = namespace; + const command = readOptionalString(configRecord.command); + if (command !== undefined) config.command = command; + const workdir = readOptionalString(configRecord.workdir); + if (workdir !== undefined) config.workdir = workdir; + const env = mapEnvListForTeam(configRecord.env); + if (env.length > 0) config.env = env; + const requestTimeoutMs = readNumber(configRecord.requestTimeoutMs); + if (requestTimeoutMs !== undefined) config.request_timeout_ms = requestTimeoutMs; + const startupTimeoutMs = readNumber(configRecord.startupTimeoutMs); + if (startupTimeoutMs !== undefined) config.startup_timeout_ms = startupTimeoutMs; + const heartbeatIntervalMs = readNumber(configRecord.heartbeatIntervalMs); + if (heartbeatIntervalMs !== undefined) config.heartbeat_interval_ms = heartbeatIntervalMs; + const staleTimeoutMs = readNumber(configRecord.staleTimeoutMs); + if (staleTimeoutMs !== undefined) config.stale_timeout_ms = staleTimeoutMs; + const restart = isRecord(configRecord.restart) ? configRecord.restart : {}; + const maxAttempts = readNumber(restart.maxAttempts); + const backoffMs = readNumber(restart.backoffMs); + if (maxAttempts !== undefined || backoffMs !== undefined) { + const restartConfig: Record = {}; + if (maxAttempts !== undefined) restartConfig.max_attempts = maxAttempts; + if (backoffMs !== undefined) restartConfig.backoff_ms = backoffMs; + config.restart = restartConfig; + } + payload.config = config; + return payload; +} + +export function buildWorkspaceRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { + const configRecord = input.config as Record; + const payload: Record = { + title: input.title, + description: existing?.description ?? '', + config: {}, + }; + const config: Record = {}; + const image = readOptionalString(configRecord.image); + if (image !== undefined) config.image = image; + const env = mapEnvListForTeam(configRecord.env); + if (env.length > 0) config.env = env; + const initialScript = readOptionalString(configRecord.initialScript); + if (initialScript !== undefined) config.initial_script = initialScript; + if (configRecord.cpu_limit !== undefined) config.cpu_limit = configRecord.cpu_limit; + if (configRecord.memory_limit !== undefined) config.memory_limit = configRecord.memory_limit; + const platform = mapWorkspacePlatformToTeam(configRecord.platform); + if (platform) config.platform = platform; + const enableDinD = readBoolean(configRecord.enableDinD ?? configRecord.enable_dind ?? configRecord.enableDind); + if (enableDinD !== undefined) config.enable_dind = enableDinD; + const ttlSeconds = readNumber(configRecord.ttlSeconds); + if (ttlSeconds !== undefined) config.ttl_seconds = ttlSeconds; + if (isRecord(configRecord.nix)) config.nix = configRecord.nix; + if (isRecord(configRecord.volumes)) { + const volumeConfig: Record = {}; + const enabled = readBoolean(configRecord.volumes.enabled); + if (enabled !== undefined) volumeConfig.enabled = enabled; + const mountPath = readOptionalString(configRecord.volumes.mountPath ?? configRecord.volumes.mount_path); + if (mountPath !== undefined) volumeConfig.mount_path = mountPath; + config.volumes = volumeConfig; + } + payload.config = config; + return payload; +} + +export function buildMemoryBucketRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { + const configRecord = input.config as Record; + const payload: Record = { + title: input.title, + description: existing?.description ?? '', + config: {}, + }; + const config: Record = {}; + const scope = mapMemoryScopeToTeam(configRecord.scope); + if (scope) config.scope = scope; + const collectionPrefix = readOptionalString(configRecord.collectionPrefix ?? configRecord.collection_prefix); + if (collectionPrefix !== undefined) config.collection_prefix = collectionPrefix; + payload.config = config; + return payload; +} diff --git a/packages/platform-ui/src/features/entities/hooks/useGraphEntities.ts b/packages/platform-ui/src/features/entities/hooks/useGraphEntities.ts deleted file mode 100644 index f290c33aa..000000000 --- a/packages/platform-ui/src/features/entities/hooks/useGraphEntities.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { graph as graphApi } from '@/api/modules/graph'; -import { useTemplates } from '@/lib/graph/hooks'; -import { notifyError, notifySuccess } from '@/lib/notify'; -import type { ApiError } from '@/api/http'; -import type { PersistedGraph } from '@agyn/shared'; -import { - applyCreateEntity, - applyDeleteEntity, - applyUpdateEntity, - buildGraphPayload, - mapGraphEntities, -} from '../api/graphEntities'; -import type { GraphEntityDeleteInput, GraphEntitySummary, GraphEntityUpsertInput } from '../types'; - -const GRAPH_QUERY_KEY = ['graph', 'full'] as const; -const CONFLICT_QUERY_KEY = ['graph', 'conflict'] as const; - -type ConflictState = { - code: string; - current?: PersistedGraph; -}; - -function extractGraphError(error: unknown): { code: string | null; current?: PersistedGraph } { - if (!error) return { code: null }; - const apiError = error as ApiError; - const payload = apiError.response?.data as { error?: unknown; current?: unknown } | undefined; - if (payload && typeof payload === 'object') { - const code = typeof payload.error === 'string' ? payload.error : null; - const current = payload.current && typeof payload.current === 'object' ? (payload.current as PersistedGraph) : undefined; - if (code || current) { - return { code, current }; - } - } - if (apiError?.message && typeof apiError.message === 'string') { - return { code: apiError.message }; - } - if (error instanceof Error && typeof error.message === 'string') { - return { code: error.message }; - } - return { code: null }; -} - -export function useGraphEntities() { - const qc = useQueryClient(); - - const graphQuery = useQuery({ - queryKey: GRAPH_QUERY_KEY, - queryFn: () => graphApi.getFullGraph(), - staleTime: 15_000, - }); - - const conflictQuery = useQuery({ - queryKey: CONFLICT_QUERY_KEY, - queryFn: async () => null, - staleTime: Infinity, - gcTime: Infinity, - }); - - const conflict = conflictQuery.data ?? null; - - const templatesQuery = useTemplates(); - - const entities: GraphEntitySummary[] = useMemo(() => { - if (!graphQuery.data) return []; - return mapGraphEntities(graphQuery.data, templatesQuery.data ?? []); - }, [graphQuery.data, templatesQuery.data]); - - const saveMutation = useMutation({ - mutationFn: (payload: ReturnType) => graphApi.saveFullGraph(payload), - onSuccess: (saved) => { - qc.setQueryData(GRAPH_QUERY_KEY, saved); - qc.setQueryData(CONFLICT_QUERY_KEY, null); - }, - onError: (error: unknown) => { - const { code, current } = extractGraphError(error); - if (code === 'VERSION_CONFLICT') { - qc.setQueryData(CONFLICT_QUERY_KEY, { code, current }); - notifyError('Graph is out of date. Refresh to continue.'); - return; - } - notifyError(code ?? 'Graph save failed'); - }, - }); - - const submit = useCallback( - async (builder: (graph: PersistedGraph) => PersistedGraph, successMessage: string) => { - const current = graphQuery.data; - if (!current) { - throw new Error('Graph is not loaded yet'); - } - const next = builder(current); - const payload = buildGraphPayload(next); - await saveMutation.mutateAsync(payload); - notifySuccess(successMessage); - }, - [graphQuery.data, saveMutation], - ); - - const createEntity = useCallback( - async (input: GraphEntityUpsertInput) => { - await submit((graph) => applyCreateEntity(graph, input), 'Entity created'); - }, - [submit], - ); - - const updateEntity = useCallback( - async (input: GraphEntityUpsertInput) => { - await submit((graph) => applyUpdateEntity(graph, input), 'Entity updated'); - }, - [submit], - ); - - const deleteEntity = useCallback( - async (input: GraphEntityDeleteInput) => { - await submit((graph) => applyDeleteEntity(graph, input), 'Entity deleted'); - }, - [submit], - ); - - const resolveConflict = useCallback(async () => { - const snapshot = conflict?.current; - if (snapshot) { - qc.setQueryData(GRAPH_QUERY_KEY, snapshot); - qc.setQueryData(CONFLICT_QUERY_KEY, null); - return; - } - await graphQuery.refetch(); - qc.setQueryData(CONFLICT_QUERY_KEY, null); - }, [conflict, graphQuery, qc]); - - const refreshGraph = useCallback(async () => { - await graphQuery.refetch(); - }, [graphQuery]); - - return { - graphQuery, - templatesQuery, - entities, - createEntity, - updateEntity, - deleteEntity, - refreshGraph, - conflict, - resolveConflict, - isSaving: saveMutation.isPending, - isLoading: graphQuery.isLoading || templatesQuery.isLoading, - } as const; -} diff --git a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts new file mode 100644 index 000000000..868ceb9ab --- /dev/null +++ b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts @@ -0,0 +1,262 @@ +import { useCallback, useMemo } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { ApiError } from '@/api/http'; +import * as teamApi from '@/api/modules/teamApi'; +import { TEAM_QUERY_KEYS, useTeamAgents, useTeamAttachments, useTeamMemoryBuckets, useTeamMcpServers, useTeamTools, useTeamWorkspaceConfigurations } from '@/api/hooks/team'; +import type { TeamAttachment } from '@/api/types/team'; +import { useTemplates } from '@/lib/graph/hooks'; +import { notifyError, notifySuccess } from '@/lib/notify'; +import { + buildAgentRequest, + buildAttachmentInputsFromRelations, + buildMcpServerRequest, + buildMemoryBucketRequest, + buildToolRequest, + buildWorkspaceRequest, + diffTeamAttachments, + mapTeamEntities, +} from '../api/teamEntities'; +import type { GraphEntityDeleteInput, GraphEntitySummary, GraphEntityUpsertInput } from '../types'; + +function extractErrorMessage(error: unknown): string { + if (!error) return 'Request failed'; + const apiError = error as ApiError; + if (apiError?.message) return apiError.message; + if (error instanceof Error) return error.message; + return 'Request failed'; +} + +function readAttachmentId(attachment: TeamAttachment): string | undefined { + if (attachment.id && attachment.id.trim().length > 0) return attachment.id; + if (attachment.meta?.id && attachment.meta.id.trim().length > 0) return attachment.meta.id; + return undefined; +} + +function readAttachmentSideId(attachment: TeamAttachment, key: 'source' | 'target'): string | undefined { + const record = attachment as Record; + const direct = record[`${key}Id`]; + if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim(); + const snake = record[`${key}_id`]; + if (typeof snake === 'string' && snake.trim().length > 0) return snake.trim(); + return undefined; +} + +export function useTeamEntities() { + const qc = useQueryClient(); + const templatesQuery = useTemplates(); + const agentsQuery = useTeamAgents(); + const toolsQuery = useTeamTools(); + const mcpServersQuery = useTeamMcpServers(); + const workspaceQuery = useTeamWorkspaceConfigurations(); + const memoryQuery = useTeamMemoryBuckets(); + const attachmentsQuery = useTeamAttachments(); + + const entities: GraphEntitySummary[] = useMemo(() => { + return mapTeamEntities( + { + agents: agentsQuery.data, + tools: toolsQuery.data, + mcpServers: mcpServersQuery.data, + workspaceConfigurations: workspaceQuery.data, + memoryBuckets: memoryQuery.data, + }, + templatesQuery.data ?? [], + ); + }, [agentsQuery.data, toolsQuery.data, mcpServersQuery.data, workspaceQuery.data, memoryQuery.data, templatesQuery.data]); + + const invalidateTeamQueries = useCallback(async () => { + await Promise.all([ + qc.invalidateQueries({ queryKey: TEAM_QUERY_KEYS.agents }), + qc.invalidateQueries({ queryKey: TEAM_QUERY_KEYS.tools }), + qc.invalidateQueries({ queryKey: TEAM_QUERY_KEYS.mcpServers }), + qc.invalidateQueries({ queryKey: TEAM_QUERY_KEYS.workspaceConfigurations }), + qc.invalidateQueries({ queryKey: TEAM_QUERY_KEYS.memoryBuckets }), + qc.invalidateQueries({ queryKey: TEAM_QUERY_KEYS.attachments }), + ]); + }, [qc]); + + const ensureAttachments = useCallback(async () => { + if (attachmentsQuery.data) return attachmentsQuery.data; + const response = await attachmentsQuery.refetch(); + return response.data ?? []; + }, [attachmentsQuery]); + + const syncAttachments = useCallback( + async (entityId: string, relations: GraphEntityUpsertInput['relations']) => { + if (!relations) return; + const current = await ensureAttachments(); + const relevant = current.filter((attachment) => { + const sourceId = readAttachmentSideId(attachment, 'source'); + const targetId = readAttachmentSideId(attachment, 'target'); + return sourceId === entityId || targetId === entityId; + }); + const desired = buildAttachmentInputsFromRelations(relations, entityId); + const { create, remove } = diffTeamAttachments(relevant, desired); + if (remove.length > 0) { + await Promise.all( + remove.map(async (attachment) => { + const id = readAttachmentId(attachment); + if (!id) return; + await teamApi.deleteAttachment(id); + }), + ); + } + if (create.length > 0) { + await Promise.all( + create.map((attachment) => + teamApi.createAttachment({ + kind: attachment.kind, + source_id: attachment.sourceId, + target_id: attachment.targetId, + sourceId: attachment.sourceId, + targetId: attachment.targetId, + }), + ), + ); + } + }, + [ensureAttachments], + ); + + const createMutation = useMutation({ + mutationFn: async (input: GraphEntityUpsertInput) => { + switch (input.entityKind) { + case 'agent': { + const created = await teamApi.createAgent(buildAgentRequest(input)); + const id = created.id ?? created.meta?.id; + if (typeof id === 'string' && id.length > 0) { + await syncAttachments(id, input.relations); + } + return created; + } + case 'tool': { + const created = await teamApi.createTool(buildToolRequest(input)); + return created; + } + case 'mcp': { + const created = await teamApi.createMcpServer(buildMcpServerRequest(input)); + const id = created.id ?? created.meta?.id; + if (typeof id === 'string' && id.length > 0) { + await syncAttachments(id, input.relations); + } + return created; + } + case 'workspace': { + return teamApi.createWorkspaceConfiguration(buildWorkspaceRequest(input)); + } + case 'memory': { + return teamApi.createMemoryBucket(buildMemoryBucketRequest(input)); + } + default: + throw new Error(`Unsupported entity kind: ${input.entityKind}`); + } + }, + onSuccess: async () => { + await invalidateTeamQueries(); + notifySuccess('Entity created'); + }, + onError: (error: unknown) => { + notifyError(extractErrorMessage(error)); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async (input: GraphEntityUpsertInput) => { + if (!input.id) throw new Error('Entity id missing'); + const existing = entities.find((entity) => entity.id === input.id); + switch (input.entityKind) { + case 'agent': { + const payload = buildAgentRequest(input, existing); + const updated = await teamApi.updateAgent(input.id, { id: input.id, ...payload }); + await syncAttachments(input.id, input.relations); + return updated; + } + case 'tool': { + const payload = buildToolRequest(input, existing); + return teamApi.updateTool(input.id, { id: input.id, ...payload }); + } + case 'mcp': { + const payload = buildMcpServerRequest(input, existing); + const updated = await teamApi.updateMcpServer(input.id, { id: input.id, ...payload }); + await syncAttachments(input.id, input.relations); + return updated; + } + case 'workspace': { + const payload = buildWorkspaceRequest(input, existing); + return teamApi.updateWorkspaceConfiguration(input.id, { id: input.id, ...payload }); + } + case 'memory': { + const payload = buildMemoryBucketRequest(input, existing); + return teamApi.updateMemoryBucket(input.id, { id: input.id, ...payload }); + } + default: + throw new Error(`Unsupported entity kind: ${input.entityKind}`); + } + }, + onSuccess: async () => { + await invalidateTeamQueries(); + notifySuccess('Entity updated'); + }, + onError: (error: unknown) => { + notifyError(extractErrorMessage(error)); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (input: GraphEntityDeleteInput) => { + switch (input.entityKind) { + case 'agent': + await teamApi.deleteAgent(input.id); + break; + case 'tool': + await teamApi.deleteTool(input.id); + break; + case 'mcp': + await teamApi.deleteMcpServer(input.id); + break; + case 'workspace': + await teamApi.deleteWorkspaceConfiguration(input.id); + break; + case 'memory': + await teamApi.deleteMemoryBucket(input.id); + break; + default: + throw new Error(`Unsupported entity kind: ${input.entityKind}`); + } + }, + onSuccess: async () => { + await invalidateTeamQueries(); + notifySuccess('Entity deleted'); + }, + onError: (error: unknown) => { + notifyError(extractErrorMessage(error)); + }, + }); + + return { + entities, + templatesQuery, + attachmentsQuery, + createEntity: createMutation.mutateAsync, + updateEntity: updateMutation.mutateAsync, + deleteEntity: deleteMutation.mutateAsync, + isSaving: createMutation.isPending || updateMutation.isPending || deleteMutation.isPending, + isLoading: + templatesQuery.isLoading || + agentsQuery.isLoading || + toolsQuery.isLoading || + mcpServersQuery.isLoading || + workspaceQuery.isLoading || + memoryQuery.isLoading || + attachmentsQuery.isLoading, + hasError: + templatesQuery.isError || + agentsQuery.isError || + toolsQuery.isError || + mcpServersQuery.isError || + workspaceQuery.isError || + memoryQuery.isError || + attachmentsQuery.isError, + } as const; +} diff --git a/packages/platform-ui/src/features/entities/types.ts b/packages/platform-ui/src/features/entities/types.ts index 1b441106c..343ec0ece 100644 --- a/packages/platform-ui/src/features/entities/types.ts +++ b/packages/platform-ui/src/features/entities/types.ts @@ -1,7 +1,8 @@ import type { PersistedGraph, PersistedGraphEdge, PersistedGraphNode } from '@agyn/shared'; import type { TemplateSchema } from '@/api/types/graph'; +import type { TeamAttachmentKind, TeamToolType } from '@/api/types/team'; -export type GraphEntityKind = 'trigger' | 'agent' | 'tool' | 'mcp' | 'workspace'; +export type GraphEntityKind = 'trigger' | 'agent' | 'tool' | 'mcp' | 'workspace' | 'memory'; export interface EntityPortDefinition { id: string; @@ -15,13 +16,17 @@ export interface EntityPortGroup { export interface GraphEntitySummary { id: string; - node: PersistedGraphNode; + entityKind: GraphEntityKind; + node?: PersistedGraphNode; title: string; + description?: string; templateName: string; templateTitle: string; templateKind: GraphEntityKind; rawTemplateKind?: string; config: Record; + toolType?: TeamToolType; + toolName?: string; state?: Record; position?: { x: number; y: number }; ports: EntityPortGroup; @@ -53,10 +58,12 @@ export interface GraphEntityRelationInput { peerHandle: string; mode: GraphRelationMode; selections: string[]; + attachmentKind?: TeamAttachmentKind; } export interface GraphEntityUpsertInput { id?: string; + entityKind: GraphEntityKind; template: string; title: string; config: Record; @@ -65,6 +72,7 @@ export interface GraphEntityUpsertInput { export interface GraphEntityDeleteInput { id: string; + entityKind: GraphEntityKind; } export type GraphEntityGraph = PersistedGraph; diff --git a/packages/platform-ui/src/features/graph/containers/AgentsGraphContainer.tsx b/packages/platform-ui/src/features/graph/containers/AgentsGraphContainer.tsx deleted file mode 100644 index ce79edebe..000000000 --- a/packages/platform-ui/src/features/graph/containers/AgentsGraphContainer.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useMemo } from 'react'; - -import { GraphLayout } from '@/components/agents/GraphLayout'; -import { graphApiService } from '@/features/graph/services/api'; -import { listVariables } from '@/features/variables/api'; - -export function AgentsGraphContainer() { - const services = useMemo(() => ({ - searchNixPackages: graphApiService.searchNixPackages, - listNixPackageVersions: graphApiService.listNixPackageVersions, - resolveNixSelection: graphApiService.resolveNixSelection, - listVariableKeys: async () => { - try { - const variables = await listVariables(); - return variables - .map((item) => item?.key) - .filter((key): key is string => typeof key === 'string' && key.length > 0); - } catch { - return []; - } - }, - }), []); - - return ; -} diff --git a/packages/platform-ui/src/layout/RootLayout.tsx b/packages/platform-ui/src/layout/RootLayout.tsx index 8b771c763..07432fd12 100644 --- a/packages/platform-ui/src/layout/RootLayout.tsx +++ b/packages/platform-ui/src/layout/RootLayout.tsx @@ -2,7 +2,6 @@ import { useCallback } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Network, - GitBranch, MessageSquare, Bell, Brain, @@ -15,7 +14,6 @@ import { Bot, Users, Layers, - Zap, Hammer, Server, Building2, @@ -26,11 +24,9 @@ import { MainLayout } from '../components/layouts/MainLayout'; import type { MenuItem } from '../components/Sidebar'; const MENU_ITEM_ROUTES: Record = { - agentsTeam: '/agents/graph', agentsThreads: '/agents/threads', agentsReminders: '/agents/reminders', agentsMemory: '/agents/memory', - entitiesTriggers: '/triggers', entitiesAgents: '/agents', entitiesTools: '/tools', entitiesMcp: '/mcp', @@ -53,7 +49,6 @@ const MENU_ITEMS: MenuItem[] = [ label: 'Agents', icon: , items: [ - { id: 'agentsTeam', label: 'Team', icon: }, { id: 'agentsThreads', label: 'Threads', icon: }, { id: 'agentsReminders', label: 'Reminders', icon: }, { id: 'agentsMemory', label: 'Memory', icon: }, @@ -64,7 +59,6 @@ const MENU_ITEMS: MenuItem[] = [ label: 'Entities', icon: , items: [ - { id: 'entitiesTriggers', label: 'Triggers', icon: }, { id: 'entitiesAgents', label: 'Agents', icon: }, { id: 'entitiesTools', label: 'Tools', icon: }, { id: 'entitiesMcp', label: 'MCP Servers', icon: }, diff --git a/packages/platform-ui/src/layout/__tests__/RootLayout.test.tsx b/packages/platform-ui/src/layout/__tests__/RootLayout.test.tsx index f036d6d87..d0f6186e6 100644 --- a/packages/platform-ui/src/layout/__tests__/RootLayout.test.tsx +++ b/packages/platform-ui/src/layout/__tests__/RootLayout.test.tsx @@ -33,7 +33,6 @@ describe('RootLayout navigation', () => { const props = renderAt('/agents/memory'); const agentsSection = props.menuItems.find((item: any) => item.id === 'agents'); expect(agentsSection?.items?.map((item: any) => item.id)).toEqual([ - 'agentsTeam', 'agentsThreads', 'agentsReminders', 'agentsMemory', @@ -45,7 +44,6 @@ describe('RootLayout navigation', () => { const props = renderAt('/agents'); const entitiesSection = props.menuItems.find((item: any) => item.id === 'entities'); expect(entitiesSection?.items?.map((item: any) => item.id)).toEqual([ - 'entitiesTriggers', 'entitiesAgents', 'entitiesTools', 'entitiesMcp', diff --git a/packages/platform-ui/src/pages/AgentsListPage.tsx b/packages/platform-ui/src/pages/AgentsListPage.tsx index ef19f8c70..343b92c72 100644 --- a/packages/platform-ui/src/pages/AgentsListPage.tsx +++ b/packages/platform-ui/src/pages/AgentsListPage.tsx @@ -5,11 +5,10 @@ export function AgentsListPage() { ); } diff --git a/packages/platform-ui/src/pages/MemoryEntitiesListPage.tsx b/packages/platform-ui/src/pages/MemoryEntitiesListPage.tsx index c0987a203..c4caaa67a 100644 --- a/packages/platform-ui/src/pages/MemoryEntitiesListPage.tsx +++ b/packages/platform-ui/src/pages/MemoryEntitiesListPage.tsx @@ -1,16 +1,16 @@ import { EntityListPage } from './entities/EntityListPage'; -import { INCLUDED_MEMORY_WORKSPACE_TEMPLATES } from '@/features/entities/api/graphEntities'; +import { INCLUDED_MEMORY_TEMPLATES } from '@/features/entities/api/teamEntities'; export function MemoryEntitiesListPage() { return ( ); } diff --git a/packages/platform-ui/src/pages/OnboardingPage.tsx b/packages/platform-ui/src/pages/OnboardingPage.tsx index 6b4ae8832..a60a1a3f6 100644 --- a/packages/platform-ui/src/pages/OnboardingPage.tsx +++ b/packages/platform-ui/src/pages/OnboardingPage.tsx @@ -7,7 +7,7 @@ export function OnboardingPage() { const location = useLocation(); const targetPath = useMemo(() => { const state = location.state as { from?: string } | null; - return state?.from ?? '/agents/graph'; + return state?.from ?? '/agents'; }, [location.state]); return ; diff --git a/packages/platform-ui/src/pages/TriggersListPage.tsx b/packages/platform-ui/src/pages/TriggersListPage.tsx deleted file mode 100644 index 23ac977fa..000000000 --- a/packages/platform-ui/src/pages/TriggersListPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { EntityListPage } from './entities/EntityListPage'; - -export function TriggersListPage() { - return ( - - ); -} diff --git a/packages/platform-ui/src/pages/WorkspacesListPage.tsx b/packages/platform-ui/src/pages/WorkspacesListPage.tsx index d6ac0dd92..60d434cc1 100644 --- a/packages/platform-ui/src/pages/WorkspacesListPage.tsx +++ b/packages/platform-ui/src/pages/WorkspacesListPage.tsx @@ -1,5 +1,5 @@ import { EntityListPage } from './entities/EntityListPage'; -import { EXCLUDED_WORKSPACE_TEMPLATES } from '@/features/entities/api/graphEntities'; +import { EXCLUDED_WORKSPACE_TEMPLATES } from '@/features/entities/api/teamEntities'; export function WorkspacesListPage() { return ( diff --git a/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx b/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx index 266ec6f8c..8b656887e 100644 --- a/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx +++ b/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx @@ -1,40 +1,15 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import React from 'react'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen, within } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; + import { server, TestProviders, abs } from '../../../__tests__/integration/testUtils'; -import type { ProvisionState } from '@/api/types/graph'; import { TemplatesProvider } from '@/lib/graph/templates.provider'; - -const pointerProto = Element.prototype as typeof Element.prototype & { - hasPointerCapture?: (pointerId: number) => boolean; - setPointerCapture?: (pointerId: number) => void; - releasePointerCapture?: (pointerId: number) => void; - scrollIntoView?: (opts?: ScrollIntoViewOptions | boolean) => void; -}; - -if (!pointerProto.hasPointerCapture) { - pointerProto.hasPointerCapture = () => false; -} -if (!pointerProto.setPointerCapture) { - pointerProto.setPointerCapture = () => {}; -} -if (!pointerProto.releasePointerCapture) { - pointerProto.releasePointerCapture = () => {}; -} -if (!pointerProto.scrollIntoView) { - pointerProto.scrollIntoView = () => {}; -} import { AgentsListPage } from '../AgentsListPage'; -import { TriggersListPage } from '../TriggersListPage'; import { ToolsListPage } from '../ToolsListPage'; import { WorkspacesListPage } from '../WorkspacesListPage'; import { MemoryEntitiesListPage } from '../MemoryEntitiesListPage'; -import { McpServersListPage } from '../McpServersListPage'; -import { EntityUpsertPage } from '../entities/EntityUpsertPage'; -import { EXCLUDED_WORKSPACE_TEMPLATES, INCLUDED_MEMORY_WORKSPACE_TEMPLATES } from '@/features/entities/api/graphEntities'; const notifications = vi.hoisted(() => ({ success: vi.fn(), @@ -48,94 +23,146 @@ vi.mock('@/lib/notify', () => ({ const templateSet = [ { - name: 'support-agent', - title: 'Support Agent', + name: 'agent', + title: 'Agent Template', kind: 'agent', sourcePorts: ['output'], targetPorts: ['input'], }, { - name: 'http-trigger', - title: 'HTTP Trigger', - kind: 'trigger', - sourcePorts: ['output'], - targetPorts: [], - }, - { - name: 'slack-tool', - title: 'Slack Tool', + name: 'manageTool', + title: 'Manage Tool', kind: 'tool', - sourcePorts: ['send'], - targetPorts: ['receive'], + sourcePorts: ['tools'], + targetPorts: [], }, { - name: 'filesystem-mcp', - title: 'Filesystem MCP', + name: 'mcpServer', + title: 'MCP Server', kind: 'mcp', sourcePorts: ['out'], targetPorts: ['in'], }, { - name: 'worker-service', - title: 'Worker Service', + name: 'workspace', + title: 'Workspace', kind: 'service', sourcePorts: ['dispatch'], targetPorts: ['ingest'], }, { name: 'memory', - title: 'Memory Workspace', - kind: 'service', - sourcePorts: [], - targetPorts: [], - }, - { - name: 'memoryConnector', - title: 'Memory Connector', + title: 'Memory Bucket', kind: 'service', sourcePorts: [], targetPorts: [], }, ]; -const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -const baseGraph = { - name: 'main', - version: 2, - updatedAt: new Date('2024-01-01T00:00:00Z').toISOString(), - nodes: [ - { id: 'trigger-1', template: 'http-trigger', config: { title: 'Webhook Trigger' } }, - { id: 'agent-1', template: 'support-agent', config: { title: 'Core Agent', description: 'Primary responder' } }, - { id: 'tool-1', template: 'slack-tool', config: { title: 'Slack Tool' } }, - { id: 'workspace-1', template: 'worker-service', config: { title: 'Worker Pool' } }, - ], - edges: [ - { id: 'edge-1', source: 'trigger-1', sourceHandle: 'output', target: 'agent-1', targetHandle: 'input' }, - ], -}; - -function primeGraphHandlers(graphOverride = baseGraph) { - const payload = JSON.parse(JSON.stringify(graphOverride)); +function primeTeamHandlers() { server.use( - http.get(abs('/api/graph'), () => HttpResponse.json(payload)), http.get(abs('/api/graph/templates'), () => HttpResponse.json(templateSet)), + http.get(abs('/api/graph/nodes/:nodeId/status'), ({ params }) => + HttpResponse.json({ nodeId: params.nodeId, isPaused: false, provisionStatus: { state: 'not_ready' } }), + ), + http.get(abs('/apiv2/team/v1/agents'), () => + HttpResponse.json({ + items: [ + { + id: 'agent-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + title: 'Core Agent', + description: 'Primary responder', + config: { model: 'gpt-4' }, + }, + ], + page: 1, + perPage: 50, + total: 1, + }), + ), + http.get(abs('/apiv2/team/v1/tools'), () => + HttpResponse.json({ + items: [ + { + id: 'tool-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + type: 'manage', + name: 'manage_team', + description: 'Manage tool', + config: { name: 'manage_team' }, + }, + ], + page: 1, + perPage: 50, + total: 1, + }), + ), + http.get(abs('/apiv2/team/v1/mcp-servers'), () => + HttpResponse.json({ + items: [ + { + id: 'mcp-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + title: 'Filesystem MCP', + description: 'Local MCP', + config: { namespace: 'fs', command: 'fs' }, + }, + ], + page: 1, + perPage: 50, + total: 1, + }), + ), + http.get(abs('/apiv2/team/v1/workspace-configurations'), () => + HttpResponse.json({ + items: [ + { + id: 'workspace-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + title: 'Worker Pool', + description: 'Default workspace', + config: { image: 'docker.io/library/node:18' }, + }, + ], + page: 1, + perPage: 50, + total: 1, + }), + ), + http.get(abs('/apiv2/team/v1/memory-buckets'), () => + HttpResponse.json({ + items: [ + { + id: 'memory-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + title: 'Global Memory', + description: 'Shared', + config: { scope: 'global' }, + }, + ], + page: 1, + perPage: 50, + total: 1, + }), + ), + http.get(abs('/apiv2/team/v1/attachments'), () => + HttpResponse.json({ + items: [], + page: 1, + perPage: 50, + total: 0, + }), + ), ); } -type MockProvisionStatus = { state: ProvisionState; details?: unknown }; - -function mockNodeStatuses(statuses: Record) { - server.use( - http.get(abs('/api/graph/nodes/:nodeId/status'), ({ params }) => { - const nodeId = params.nodeId as string; - const payload = statuses[nodeId] ?? { state: 'not_ready' as ProvisionState }; - return HttpResponse.json({ nodeId, isPaused: false, provisionStatus: payload }); - }), - ); -} - -function renderWithGraphProviders(children: React.ReactNode) { +function renderWithProviders(children: React.ReactNode) { render( @@ -145,18 +172,6 @@ function renderWithGraphProviders(children: React.ReactNode) { ); } -function renderWithEntityRoutes(initialEntries: string[], routes: React.ReactNode) { - render( - - - - {routes} - - - , - ); -} - describe('Entity list pages', () => { beforeAll(() => server.listen()); afterAll(() => server.close()); @@ -167,577 +182,48 @@ describe('Entity list pages', () => { }); it('renders agent rows without mixing entity kinds', async () => { - primeGraphHandlers(); + primeTeamHandlers(); - renderWithGraphProviders(); + renderWithProviders(); const titleElement = await screen.findByText('Core Agent', { selector: '[data-testid="entity-title"]' }); - const titleCell = titleElement.closest('td'); - expect(titleCell).not.toBeNull(); - expect(within(titleCell as HTMLTableCellElement).queryByText('Primary responder')).not.toBeInTheDocument(); - const row = titleElement.closest('tr'); expect(row).not.toBeNull(); const templateLabel = within(row as HTMLTableRowElement).getByTestId('entity-template'); - expect(templateLabel).toHaveTextContent('Support Agent'); - - expect(screen.queryByText('Webhook Trigger')).not.toBeInTheDocument(); - }); - - it('renders only workspaces on the workspaces page without memory controls', async () => { - primeGraphHandlers(); - - renderWithGraphProviders(); - - await screen.findByText('Worker Pool'); - expect(screen.queryByText('Core Agent')).not.toBeInTheDocument(); - expect(screen.queryByText('Webhook Trigger')).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /open memory manager/i })).not.toBeInTheDocument(); - }); - - it('excludes memory workspace templates from the workspaces list', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'workspace-1', template: 'worker-service', config: { title: 'Worker Pool' } }, - { id: 'workspace-2', template: 'memory', config: { title: 'Memory Root' } }, - { id: 'workspace-3', template: 'memoryConnector', config: { title: 'Memory Connector' } }, - ], - }; - primeGraphHandlers(graphOverride); - - renderWithGraphProviders(); - - await screen.findByText('Worker Pool'); - expect(screen.queryByText('Memory Root')).not.toBeInTheDocument(); - expect(screen.queryByText('Memory Connector')).not.toBeInTheDocument(); - }); - - it('excludes memory workspace templates from the workspace create page', async () => { - primeGraphHandlers(); - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/workspaces/new'], - - )} - />, - ); - - const templateSelect = await screen.findByRole('combobox', { name: /template/i }); - await user.click(templateSelect); - expect(await screen.findByRole('option', { name: 'Worker Service' })).toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'Memory Workspace' })).not.toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'Memory Connector' })).not.toBeInTheDocument(); - }); - - it('keeps MCP servers separate from tools, including template picker', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'tool-1', template: 'slack-tool', config: { title: 'Slack Tool' } }, - { id: 'mcp-1', template: 'filesystem-mcp', config: { title: 'Filesystem MCP' } }, - ], - edges: [], - }; - primeGraphHandlers(graphOverride); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/tools'], - <> - } /> - } /> - , - ); - - await screen.findByText('Slack Tool', { selector: '[data-testid="entity-title"]' }); - expect(screen.queryByText('Filesystem MCP', { selector: '[data-testid="entity-title"]' })).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /new tool/i })); - - const templateSelect = await screen.findByRole('combobox', { name: /template/i }); - await user.click(templateSelect); - expect(await screen.findByRole('option', { name: 'Slack Tool' })).toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'Filesystem MCP' })).not.toBeInTheDocument(); - }); - - it('renders only memory entities on the memory page', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'memory-1', template: 'memory', config: { title: 'Memory Root' } }, - { id: 'workspace-1', template: 'worker-service', config: { title: 'Worker Pool' } }, - ], - edges: [], - }; - primeGraphHandlers(graphOverride); - - renderWithGraphProviders(); - - await screen.findByText('Memory Root'); + expect(templateLabel).toHaveTextContent('Agent Template'); expect(screen.queryByText('Worker Pool')).not.toBeInTheDocument(); - expect(screen.queryByText('Core Agent')).not.toBeInTheDocument(); - expect(screen.queryByText('Webhook Trigger')).not.toBeInTheDocument(); }); - it('displays provision statuses (ready/provisioning/error) with inline error details', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'agent-ready', template: 'support-agent', config: { title: 'Ready Agent' } }, - { id: 'agent-provisioning', template: 'support-agent', config: { title: 'Provisioning Agent' } }, - { id: 'agent-error', template: 'support-agent', config: { title: 'Broken Agent' } }, - ], - edges: [], - }; - primeGraphHandlers(graphOverride); - mockNodeStatuses({ - 'agent-ready': { state: 'ready' }, - 'agent-provisioning': { state: 'provisioning' }, - 'agent-error': { state: 'error', details: { message: 'boom' } }, - }); - - renderWithGraphProviders(); - - const readyCell = await screen.findByText('Ready Agent', { selector: '[data-testid="entity-title"]' }); - const readyRow = readyCell.closest('tr'); - expect(readyRow).not.toBeNull(); - expect(within(readyRow as HTMLTableRowElement).getByTestId('entity-status-cell')).toHaveTextContent('ready'); - - const provisioningCell = await screen.findByText('Provisioning Agent', { selector: '[data-testid="entity-title"]' }); - const provisioningRow = provisioningCell.closest('tr'); - expect(provisioningRow).not.toBeNull(); - expect(within(provisioningRow as HTMLTableRowElement).getByTestId('entity-status-cell')).toHaveTextContent('provisioning'); - - const errorCell = await screen.findByText('Broken Agent', { selector: '[data-testid="entity-title"]' }); - const errorRow = errorCell.closest('tr'); - expect(errorRow).not.toBeNull(); - expect(within(errorRow as HTMLTableRowElement).getByTestId('entity-status-cell')).toHaveTextContent('error'); - expect(within(errorRow as HTMLTableRowElement).getByTestId('entity-status-error')).toHaveTextContent('{"message":"boom"}'); - }); - - it('limits the memory create page to memory templates only', async () => { - const graphOverride = { - ...baseGraph, - nodes: [{ id: 'memory-1', template: 'memory', config: { title: 'Memory Root' } }], - edges: [], - }; - primeGraphHandlers(graphOverride); - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/memory/new'], - - )} - />, - ); - - const templateSelect = await screen.findByRole('combobox', { name: /template/i }); - await user.click(templateSelect); - expect(await screen.findByRole('option', { name: 'Memory Workspace' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'Memory Connector' })).toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'Worker Service' })).not.toBeInTheDocument(); - }); - - it('renders only MCP servers on the MCP page and limits templates accordingly', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'mcp-1', template: 'filesystem-mcp', config: { title: 'Filesystem MCP' } }, - { id: 'tool-1', template: 'slack-tool', config: { title: 'Slack Tool' } }, - ], - edges: [], - }; - primeGraphHandlers(graphOverride); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/mcp'], - <> - } /> - } /> - , - ); - - await screen.findByText('Filesystem MCP', { selector: '[data-testid="entity-title"]' }); - expect(screen.queryByText('Slack Tool', { selector: '[data-testid="entity-title"]' })).not.toBeInTheDocument(); + it('renders tools and templates for tool entries', async () => { + primeTeamHandlers(); - await user.click(screen.getByRole('button', { name: /new mcp server/i })); + renderWithProviders(); - const templateSelect = await screen.findByRole('combobox', { name: /template/i }); - await user.click(templateSelect); - expect(await screen.findByRole('option', { name: 'Filesystem MCP' })).toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'Slack Tool' })).not.toBeInTheDocument(); - }); - - it('renders the MCP config view in the edit dialog', async () => { - const graphOverride = { - ...baseGraph, - nodes: [{ id: 'mcp-1', template: 'filesystem-mcp', config: { title: 'Filesystem MCP' } }], - edges: [], - }; - primeGraphHandlers(graphOverride); - - server.use( - http.get(abs('/api/graph/nodes/mcp-1/state'), () => - HttpResponse.json({ state: { mcp: { tools: [{ name: 'search', title: 'Search' }], enabledTools: ['search'] } } }), - ), - ); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/mcp'], - <> - } /> - } /> - , - ); - - const titleCell = await screen.findByText('Filesystem MCP', { selector: '[data-testid="entity-title"]' }); - const row = titleCell.closest('tr'); + const titleElement = await screen.findByText('Manage tool', { selector: '[data-testid="entity-title"]' }); + const row = titleElement.closest('tr'); expect(row).not.toBeNull(); - await user.click(within(row as HTMLTableRowElement).getByRole('button', { name: /edit/i })); - - await screen.findByText('Namespace'); - expect(screen.getByPlaceholderText('npx -y @modelcontextprotocol/server-everything')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument(); - }); - - it('saves memory entity edits when no edges are present', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'memory-1', template: 'memory', config: { title: 'Memory Root' } }, - { id: 'memory-2', template: 'memoryConnector', config: { title: 'Memory Connector' } }, - ], - edges: [], - }; - primeGraphHandlers(graphOverride); - - const savedPayload: { body?: any } = {}; - server.use( - http.post(abs('/api/graph'), async ({ request }) => { - savedPayload.body = await request.json(); - return HttpResponse.json({ - ...graphOverride, - version: graphOverride.version + 1, - nodes: graphOverride.nodes.map((node) => - node.id === 'memory-1' ? { ...node, config: { ...node.config, title: 'Memory Updated' } } : node, - ), - }); - }), - ); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/memory'], - <> - } /> - - )} - /> - , - ); - - await screen.findByText('Memory Root'); - await user.click(screen.getAllByRole('button', { name: /edit/i })[0]); - - const titleInput = await screen.findByLabelText('Entity title'); - await user.clear(titleInput); - await user.type(titleInput, 'Memory Updated'); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(notifications.success).toHaveBeenCalledWith('Entity updated')); - expect(savedPayload.body?.edges).toEqual([]); - }); - - it('shows trigger entities in the triggers list', async () => { - primeGraphHandlers(); - - renderWithGraphProviders(); - - await screen.findByText('Webhook Trigger'); - expect(screen.queryByText('Core Agent')).not.toBeInTheDocument(); - }); - - it('creates a new agent and persists the graph', async () => { - primeGraphHandlers(); - - const savedPayload: { body?: any } = {}; - server.use( - http.post(abs('/api/graph'), async ({ request }) => { - savedPayload.body = await request.json(); - return HttpResponse.json({ - ...baseGraph, - version: baseGraph.version + 1, - nodes: [ - ...baseGraph.nodes, - { id: 'agent-new', template: 'support-agent', config: { title: 'Responder' } }, - ], - }); - }), - ); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/agents'], - <> - } /> - } /> - , - ); - - await screen.findByText('Core Agent'); - await user.click(screen.getByRole('button', { name: /new agent/i })); - - const templateSelect = screen.getByRole('combobox', { name: /template/i }); - await user.click(templateSelect); - await user.click(await screen.findByRole('option', { name: 'Support Agent' })); - - const titleInput = await screen.findByLabelText('Entity title'); - await user.clear(titleInput); - await user.type(titleInput, 'Responder'); - - await user.click(screen.getByRole('button', { name: /create/i })); - - await waitFor(() => expect(notifications.success).toHaveBeenCalledWith('Entity created')); - expect(savedPayload.body).toBeDefined(); - const createdNode = savedPayload.body.nodes.find( - (node: any) => node.template === 'support-agent' && node.config?.title === 'Responder', - ); - expect(createdNode).toBeDefined(); - expect(createdNode.id).toMatch(uuidRegex); - expect(savedPayload.body.edges).toEqual(baseGraph.edges); - }); - - it('shows conflict banner when graph version is stale and refreshes on demand', async () => { - primeGraphHandlers(); - - server.use( - http.post(abs('/api/graph'), async () => - new HttpResponse( - JSON.stringify({ error: 'VERSION_CONFLICT', current: { ...baseGraph, version: baseGraph.version + 1 } }), - { status: 409, headers: { 'Content-Type': 'application/json' } }, - ), - ), - ); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/agents'], - <> - } /> - } /> - , - ); - - await screen.findByText('Core Agent'); - await user.click(screen.getAllByRole('button', { name: /edit/i })[0]); - - const titleInput = await screen.findByLabelText('Entity title'); - await user.clear(titleInput); - await user.type(titleInput, 'Updated'); - await user.click(screen.getByRole('button', { name: /save changes/i })); - - await waitFor(() => expect(notifications.error).toHaveBeenCalled()); - await screen.findByText('Unable to save entity. Please try again.'); - - await user.click(screen.getByRole('button', { name: /cancel/i })); - - const alert = await screen.findByText('Graph updated elsewhere'); - expect(alert).toBeVisible(); - - const refreshButton = await screen.findByRole('button', { name: /refresh graph/i, hidden: true }); - await user.click(refreshButton); - await waitFor(() => expect(screen.queryByText('Graph updated elsewhere')).not.toBeInTheDocument()); - }); - - it('requires selecting a template before enabling create actions', async () => { - primeGraphHandlers(); - - const user = userEvent.setup({ pointerEventsCheck: 0 }); - - renderWithEntityRoutes( - ['/agents/new'], - } />, - ); - - const createButton = screen.getByRole('button', { name: /create/i }); - expect(createButton).toBeDisabled(); - - const templateSelect = await screen.findByRole('combobox', { name: /template/i }); - await user.click(templateSelect); - await user.click(await screen.findByRole('option', { name: 'Support Agent' })); - await waitFor(() => expect(templateSelect).not.toBeDisabled()); - - await waitFor(() => expect(createButton).not.toBeDisabled()); - }); - - it('sorts entity rows by title and template columns', async () => { - const alphaAgentTemplate = { - name: 'alpha-agent', - title: 'Atlas Agent', - kind: 'agent', - sourcePorts: ['output'], - targetPorts: ['input'], - }; - const sortGraph = { - ...baseGraph, - nodes: [ - { id: 'agent-zulu', template: 'support-agent', config: { title: 'Zulu Agent' } }, - { id: 'agent-alpha', template: 'alpha-agent', config: { title: 'Alpha Agent' } }, - ], - edges: [], - }; - primeGraphHandlers(sortGraph); - server.use(http.get(abs('/api/graph/templates'), () => HttpResponse.json([...templateSet, alphaAgentTemplate]))); - - const user = userEvent.setup(); - - renderWithGraphProviders(); - - await screen.findByText('Zulu Agent'); - await screen.findByText('Alpha Agent'); - - const readTitles = () => screen.getAllByTestId('entity-title').map((node) => node.textContent ?? ''); - - expect(readTitles()).toEqual(['Alpha Agent', 'Zulu Agent']); - - await user.click(screen.getByRole('button', { name: /sort by title/i })); - expect(readTitles()).toEqual(['Zulu Agent', 'Alpha Agent']); - - await user.click(screen.getByRole('button', { name: /sort by template/i })); - expect(readTitles()).toEqual(['Alpha Agent', 'Zulu Agent']); + const templateLabel = within(row as HTMLTableRowElement).getByTestId('entity-template'); + expect(templateLabel).toHaveTextContent('Manage Tool'); }); - it('sends provision/deprovision requests with correct payloads and disables both controls while pending', async () => { - const graphOverride = { - ...baseGraph, - nodes: [ - { id: 'agent-error', template: 'support-agent', config: { title: 'Broken Agent' } }, - { id: 'agent-ready', template: 'support-agent', config: { title: 'Ready Agent' } }, - ], - edges: [], - }; - primeGraphHandlers(graphOverride); - mockNodeStatuses({ - 'agent-error': { state: 'error' }, - 'agent-ready': { state: 'ready' }, - }); - - const requests: Array<{ nodeId: string; action?: string }> = []; - server.use( - http.post(abs('/api/graph/nodes/:nodeId/actions'), async ({ request, params }) => { - const body = (await request.json()) as { action?: string }; - requests.push({ nodeId: params.nodeId as string, action: body?.action }); - await new Promise((resolve) => setTimeout(resolve, 20)); - return new HttpResponse(null, { status: 204 }); - }), - ); - - const user = userEvent.setup(); + it('renders only workspaces on the workspaces page', async () => { + primeTeamHandlers(); - renderWithGraphProviders(); + renderWithProviders(); - const brokenAgentCell = await screen.findByText('Broken Agent', { selector: '[data-testid="entity-title"]' }); - const brokenRow = brokenAgentCell.closest('tr') as HTMLTableRowElement; - const brokenStatusCell = within(brokenRow).getByTestId('entity-status-cell'); - const provisionButton = within(brokenStatusCell).getByRole('button', { name: /^Provision$/i }); - expect(provisionButton).not.toBeDisabled(); - expect(within(brokenStatusCell).queryByRole('button', { name: /^Deprovision$/i })).toBeNull(); - - await user.click(provisionButton); - expect(provisionButton).toBeDisabled(); - await waitFor(() => expect(brokenStatusCell).toHaveTextContent('provisioning')); - await waitFor(() => expect(provisionButton).not.toBeDisabled()); - await waitFor(() => expect(brokenStatusCell).toHaveTextContent('error')); - - const readyAgentCell = await screen.findByText('Ready Agent', { selector: '[data-testid="entity-title"]' }); - const readyRow = readyAgentCell.closest('tr') as HTMLTableRowElement; - const readyStatusCell = within(readyRow).getByTestId('entity-status-cell'); - const readyDeprovisionButton = within(readyStatusCell).getByRole('button', { name: /^Deprovision$/i }); - expect(readyDeprovisionButton).not.toBeDisabled(); - expect(within(readyStatusCell).queryByRole('button', { name: /^Provision$/i })).toBeNull(); - - await user.click(readyDeprovisionButton); - expect(readyDeprovisionButton).toBeDisabled(); - const reenabledDeprovisionButton = await within(readyStatusCell).findByRole('button', { name: /^Deprovision$/i }); - expect(reenabledDeprovisionButton).not.toBeDisabled(); - - expect(requests).toEqual([ - { nodeId: 'agent-error', action: 'provision' }, - { nodeId: 'agent-ready', action: 'deprovision' }, - ]); + await screen.findByText('Worker Pool'); + expect(screen.queryByText('Global Memory')).not.toBeInTheDocument(); }); - it('shows the stop icon while provisioning and sends a deprovision request', async () => { - const graphOverride = { - ...baseGraph, - nodes: [{ id: 'agent-provisioning', template: 'support-agent', config: { title: 'Provisioning Agent' } }], - edges: [], - }; - primeGraphHandlers(graphOverride); - mockNodeStatuses({ - 'agent-provisioning': { state: 'provisioning' }, - }); + it('renders memory buckets on the memory page', async () => { + primeTeamHandlers(); - const requests: Array<{ nodeId: string; action?: string }> = []; - server.use( - http.post(abs('/api/graph/nodes/:nodeId/actions'), async ({ request, params }) => { - const body = (await request.json()) as { action?: string }; - requests.push({ nodeId: params.nodeId as string, action: body?.action }); - await new Promise((resolve) => setTimeout(resolve, 20)); - return new HttpResponse(null, { status: 204 }); - }), - ); - - const user = userEvent.setup(); - - renderWithGraphProviders(); - - const provisioningAgentCell = await screen.findByText('Provisioning Agent', { selector: '[data-testid="entity-title"]' }); - const provisioningRow = provisioningAgentCell.closest('tr') as HTMLTableRowElement; - const provisioningStatusCell = within(provisioningRow).getByTestId('entity-status-cell'); - await waitFor(() => expect(provisioningStatusCell).toHaveTextContent(/\bprovisioning\b/)); - const stopButton = await within(provisioningStatusCell).findByRole('button', { name: /^Deprovision$/i }); - - expect(stopButton).not.toBeDisabled(); - expect(provisioningStatusCell.querySelector('svg.lucide-square')).not.toBeNull(); + renderWithProviders(); - await user.click(stopButton); - expect(stopButton).toBeDisabled(); - - await waitFor(() => expect(requests).toEqual([{ nodeId: 'agent-provisioning', action: 'deprovision' }])); + const titleElement = await screen.findByText('Global Memory', { selector: '[data-testid="entity-title"]' }); + const row = titleElement.closest('tr'); + expect(row).not.toBeNull(); + const templateLabel = within(row as HTMLTableRowElement).getByTestId('entity-template'); + expect(templateLabel).toHaveTextContent('Memory Bucket'); }); }); diff --git a/packages/platform-ui/src/pages/entities/EntityListPage.tsx b/packages/platform-ui/src/pages/entities/EntityListPage.tsx index 154086e78..80f3df5bb 100644 --- a/packages/platform-ui/src/pages/entities/EntityListPage.tsx +++ b/packages/platform-ui/src/pages/entities/EntityListPage.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { Plus, RefreshCw } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { EntityTable, type EntityTableSortKey, type EntityTableSortState } from '@/components/entities/EntityTable'; -import { useGraphEntities } from '@/features/entities/hooks/useGraphEntities'; -import { getTemplateOptions } from '@/features/entities/api/graphEntities'; +import { useTeamEntities } from '@/features/entities/hooks/useTeamEntities'; +import { getTemplateOptions } from '@/features/entities/api/teamEntities'; import type { GraphEntityKind, GraphEntitySummary } from '@/features/entities/types'; interface ToolbarAction { @@ -36,7 +36,7 @@ export function EntityListPage({ templateExcludeNames, }: EntityListPageProps) { const navigate = useNavigate(); - const { entities, deleteEntity, graphQuery, templatesQuery, conflict, resolveConflict, isSaving } = useGraphEntities(); + const { entities, deleteEntity, templatesQuery, isSaving, isLoading, hasError } = useTeamEntities(); const [sort, setSort] = useState({ key: 'title', direction: 'asc' }); const templates = useMemo(() => { @@ -49,7 +49,7 @@ export function EntityListPage({ const filteredEntities = useMemo(() => { return entities.filter((entity) => { - if (entity.templateKind !== kind) { + if (entity.entityKind !== kind) { return false; } if (templateIncludeNames && templateIncludeNames.size > 0 && !templateIncludeNames.has(entity.templateName)) { @@ -73,8 +73,6 @@ export function EntityListPage({ }); }, [filteredEntities, sort]); - const isLoading = graphQuery.isLoading || templatesQuery.isLoading; - const handleCreateClick = () => { navigate(`${listPath}/new`); }; @@ -86,7 +84,7 @@ export function EntityListPage({ const handleDeleteClick = async (entity: GraphEntitySummary) => { const confirmed = window.confirm(`Delete ${entity.title}? This action cannot be undone.`); if (!confirmed) return; - await deleteEntity({ id: entity.id }); + await deleteEntity({ id: entity.id, entityKind: entity.entityKind }); }; const showEmptyLabel = emptyLabel; @@ -133,31 +131,12 @@ export function EntityListPage({
- {(conflict || graphQuery.isError || templatesQuery.isError) && ( + {(hasError || templatesQuery.isError) && (
- {conflict && ( - - Graph updated elsewhere - - Latest graph changes are available. Refresh to continue editing. - - - - )} - - {(graphQuery.isError || templatesQuery.isError) && ( - - Unable to load graph data - Check your connection and try again. - - )} + + Unable to load entities + Check your connection and try again. +
)} diff --git a/packages/platform-ui/src/pages/entities/EntityUpsertPage.tsx b/packages/platform-ui/src/pages/entities/EntityUpsertPage.tsx index 6d627858f..ae4e3c21f 100644 --- a/packages/platform-ui/src/pages/entities/EntityUpsertPage.tsx +++ b/packages/platform-ui/src/pages/entities/EntityUpsertPage.tsx @@ -2,9 +2,13 @@ import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { EntityUpsertForm } from '@/components/entities/EntityUpsertForm'; -import { useGraphEntities } from '@/features/entities/hooks/useGraphEntities'; -import { getTemplateOptions } from '@/features/entities/api/graphEntities'; -import { mapPersistedGraphToNodes } from '@/features/graph/mappers'; +import { useTeamEntities } from '@/features/entities/hooks/useTeamEntities'; +import { + getTemplateOptions, + limitTemplateOptionsForKind, + mapTeamAttachmentsToEdges, + mapTeamEntitiesToGraphNodes, +} from '@/features/entities/api/teamEntities'; import type { GraphEntityKind } from '@/features/entities/types'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; @@ -25,28 +29,25 @@ export function EntityUpsertPage({ }: EntityUpsertPageProps) { const navigate = useNavigate(); const { entityId } = useParams<{ entityId?: string }>(); - const { entities, createEntity, updateEntity, graphQuery, templatesQuery, isSaving } = useGraphEntities(); + const { entities, createEntity, updateEntity, templatesQuery, attachmentsQuery, isSaving, isLoading } = useTeamEntities(); const templates = useMemo(() => { const options = getTemplateOptions(templatesQuery.data ?? [], kind, templateExcludeNames); - if (!templateIncludeNames || templateIncludeNames.size === 0) { - return options; - } - return options.filter((option) => templateIncludeNames.has(option.name)); + const filtered = !templateIncludeNames || templateIncludeNames.size === 0 + ? options + : options.filter((option) => templateIncludeNames.has(option.name)); + return limitTemplateOptionsForKind(filtered, kind); }, [kind, templateExcludeNames, templateIncludeNames, templatesQuery.data]); const graphNodes = useMemo(() => { - if (!graphQuery.data) return []; - return mapPersistedGraphToNodes(graphQuery.data, templatesQuery.data ?? []).nodes; - }, [graphQuery.data, templatesQuery.data]); + return mapTeamEntitiesToGraphNodes(entities, templatesQuery.data ?? []); + }, [entities, templatesQuery.data]); const graphEdges = useMemo(() => { - if (!graphQuery.data?.edges) return []; - return graphQuery.data.edges.filter((edge): edge is GraphPersistedEdge => Boolean(edge)); - }, [graphQuery.data]); + return mapTeamAttachmentsToEdges(attachmentsQuery.data); + }, [attachmentsQuery.data]); const editableEntity = mode === 'edit' ? entities.find((item) => item.id === entityId) : undefined; - const isLoading = graphQuery.isLoading || templatesQuery.isLoading; const showForm = mode === 'create' || Boolean(editableEntity); const handleSubmit = async (input: Parameters[0]) => { From a561b87b5731952f335094ff765b7ed25fcc443a Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 12 Mar 2026 21:54:17 +0000 Subject: [PATCH 13/43] fix(platform-server): align teams grpc types --- .../__tests__/teams/teamsGrpc.client.test.ts | 6 +- packages/platform-server/package.json | 1 + .../src/teams/teamsGrpc.client.ts | 166 ++++++++---------- pnpm-lock.yaml | 3 + 4 files changed, 82 insertions(+), 94 deletions(-) diff --git a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts index f2b8e60a8..eae029420 100644 --- a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -4,7 +4,7 @@ import { HttpStatus } from '@nestjs/common'; import { ListAgentsRequestSchema, type ListAgentsRequest, - type ListAgentsResponse, + type PaginatedAgents, TeamsService, } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb.js'; import { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; @@ -89,7 +89,7 @@ describe('TeamsGrpcClient', () => { const listAgentsStub = vi.fn(async (_req: ListAgentsRequest, options?: CallOptions) => { captured.options = options; - return { agents: [], nextPageToken: '' } as ListAgentsResponse; + return { items: [], page: 0, perPage: 0, total: 0n } as PaginatedAgents; }); (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { @@ -108,7 +108,7 @@ describe('TeamsGrpcClient', () => { const listAgentsStub = vi.fn(async (_req: ListAgentsRequest, options?: CallOptions) => { captured.options = options; - return { agents: [], nextPageToken: '' } as ListAgentsResponse; + return { items: [], page: 0, perPage: 0, total: 0n } as PaginatedAgents; }); (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 0b7bd604a..522352996 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -30,6 +30,7 @@ "@connectrpc/connect-node": "^2.1.1", "@fastify/cors": "^11.1.0", "@fastify/websocket": "^11.2.0", + "@grpc/grpc-js": "^1.11.1", "@langchain/core": "1.0.1", "@langchain/langgraph": "1.0.0", "@langchain/langgraph-checkpoint": "1.0.0", diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts index 81f84a449..978b1bb1b 100644 --- a/packages/platform-server/src/teams/teamsGrpc.client.ts +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -3,12 +3,9 @@ import { HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Code, ConnectError, createClient, type CallOptions, type Client } from '@connectrpc/connect'; import { createGrpcTransport } from '@connectrpc/connect-node'; import { - CreateAgentRequestSchema, - CreateAttachmentRequestSchema, - CreateMcpServerRequestSchema, - CreateMemoryBucketRequestSchema, - CreateToolRequestSchema, - CreateWorkspaceConfigurationRequestSchema, + AgentCreateRequestSchema, + AgentUpdateRequestSchema, + AttachmentCreateRequestSchema, DeleteAgentRequestSchema, DeleteAttachmentRequestSchema, DeleteMcpServerRequestSchema, @@ -26,70 +23,57 @@ import { ListMemoryBucketsRequestSchema, ListToolsRequestSchema, ListWorkspaceConfigurationsRequestSchema, + McpServerCreateRequestSchema, + McpServerUpdateRequestSchema, + MemoryBucketCreateRequestSchema, + MemoryBucketUpdateRequestSchema, TeamsService, - UpdateAgentRequestSchema, - UpdateMcpServerRequestSchema, - UpdateMemoryBucketRequestSchema, - UpdateToolRequestSchema, - UpdateWorkspaceConfigurationRequestSchema, + ToolCreateRequestSchema, + ToolUpdateRequestSchema, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationUpdateRequestSchema, } from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; import type { - CreateAgentRequest, - CreateAgentResponse, - CreateAttachmentRequest, - CreateAttachmentResponse, - CreateMcpServerRequest, - CreateMcpServerResponse, - CreateMemoryBucketRequest, - CreateMemoryBucketResponse, - CreateToolRequest, - CreateToolResponse, - CreateWorkspaceConfigurationRequest, - CreateWorkspaceConfigurationResponse, + Agent, + AgentCreateRequest, + AgentUpdateRequest, + Attachment, + AttachmentCreateRequest, DeleteAgentRequest, - DeleteAgentResponse, DeleteAttachmentRequest, - DeleteAttachmentResponse, DeleteMcpServerRequest, - DeleteMcpServerResponse, DeleteMemoryBucketRequest, - DeleteMemoryBucketResponse, DeleteToolRequest, - DeleteToolResponse, DeleteWorkspaceConfigurationRequest, - DeleteWorkspaceConfigurationResponse, GetAgentRequest, - GetAgentResponse, GetMcpServerRequest, - GetMcpServerResponse, GetMemoryBucketRequest, - GetMemoryBucketResponse, GetToolRequest, - GetToolResponse, GetWorkspaceConfigurationRequest, - GetWorkspaceConfigurationResponse, ListAgentsRequest, - ListAgentsResponse, ListAttachmentsRequest, - ListAttachmentsResponse, ListMcpServersRequest, - ListMcpServersResponse, ListMemoryBucketsRequest, - ListMemoryBucketsResponse, ListToolsRequest, - ListToolsResponse, ListWorkspaceConfigurationsRequest, - ListWorkspaceConfigurationsResponse, - UpdateAgentRequest, - UpdateAgentResponse, - UpdateMcpServerRequest, - UpdateMcpServerResponse, - UpdateMemoryBucketRequest, - UpdateMemoryBucketResponse, - UpdateToolRequest, - UpdateToolResponse, - UpdateWorkspaceConfigurationRequest, - UpdateWorkspaceConfigurationResponse, + McpServer, + McpServerCreateRequest, + McpServerUpdateRequest, + MemoryBucket, + MemoryBucketCreateRequest, + MemoryBucketUpdateRequest, + PaginatedAgents, + PaginatedAttachments, + PaginatedMcpServers, + PaginatedMemoryBuckets, + PaginatedTools, + PaginatedWorkspaceConfigurations, + Tool, + ToolCreateRequest, + ToolUpdateRequest, + WorkspaceConfiguration, + WorkspaceConfigurationCreateRequest, + WorkspaceConfigurationUpdateRequest, } from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; type TeamsGrpcClientConfig = { @@ -147,7 +131,7 @@ export class TeamsGrpcClient { return this.endpoint; } - async listAgents(request: ListAgentsRequest): Promise { + async listAgents(request: ListAgentsRequest): Promise { return this.call( teamsServicePath('listAgents'), ListAgentsRequestSchema, @@ -156,16 +140,16 @@ export class TeamsGrpcClient { ); } - async createAgent(request: CreateAgentRequest): Promise { + async createAgent(request: AgentCreateRequest): Promise { return this.call( teamsServicePath('createAgent'), - CreateAgentRequestSchema, + AgentCreateRequestSchema, request, 'createAgent', ); } - async getAgent(request: GetAgentRequest): Promise { + async getAgent(request: GetAgentRequest): Promise { return this.call( teamsServicePath('getAgent'), GetAgentRequestSchema, @@ -174,17 +158,17 @@ export class TeamsGrpcClient { ); } - async updateAgent(request: UpdateAgentRequest): Promise { + async updateAgent(request: AgentUpdateRequest): Promise { return this.call( teamsServicePath('updateAgent'), - UpdateAgentRequestSchema, + AgentUpdateRequestSchema, request, 'updateAgent', ); } async deleteAgent(request: DeleteAgentRequest): Promise { - await this.call( + await this.call( teamsServicePath('deleteAgent'), DeleteAgentRequestSchema, request, @@ -192,7 +176,7 @@ export class TeamsGrpcClient { ); } - async listTools(request: ListToolsRequest): Promise { + async listTools(request: ListToolsRequest): Promise { return this.call( teamsServicePath('listTools'), ListToolsRequestSchema, @@ -201,16 +185,16 @@ export class TeamsGrpcClient { ); } - async createTool(request: CreateToolRequest): Promise { + async createTool(request: ToolCreateRequest): Promise { return this.call( teamsServicePath('createTool'), - CreateToolRequestSchema, + ToolCreateRequestSchema, request, 'createTool', ); } - async getTool(request: GetToolRequest): Promise { + async getTool(request: GetToolRequest): Promise { return this.call( teamsServicePath('getTool'), GetToolRequestSchema, @@ -219,17 +203,17 @@ export class TeamsGrpcClient { ); } - async updateTool(request: UpdateToolRequest): Promise { + async updateTool(request: ToolUpdateRequest): Promise { return this.call( teamsServicePath('updateTool'), - UpdateToolRequestSchema, + ToolUpdateRequestSchema, request, 'updateTool', ); } async deleteTool(request: DeleteToolRequest): Promise { - await this.call( + await this.call( teamsServicePath('deleteTool'), DeleteToolRequestSchema, request, @@ -237,7 +221,7 @@ export class TeamsGrpcClient { ); } - async listMcpServers(request: ListMcpServersRequest): Promise { + async listMcpServers(request: ListMcpServersRequest): Promise { return this.call( teamsServicePath('listMcpServers'), ListMcpServersRequestSchema, @@ -246,16 +230,16 @@ export class TeamsGrpcClient { ); } - async createMcpServer(request: CreateMcpServerRequest): Promise { + async createMcpServer(request: McpServerCreateRequest): Promise { return this.call( teamsServicePath('createMcpServer'), - CreateMcpServerRequestSchema, + McpServerCreateRequestSchema, request, 'createMcpServer', ); } - async getMcpServer(request: GetMcpServerRequest): Promise { + async getMcpServer(request: GetMcpServerRequest): Promise { return this.call( teamsServicePath('getMcpServer'), GetMcpServerRequestSchema, @@ -264,17 +248,17 @@ export class TeamsGrpcClient { ); } - async updateMcpServer(request: UpdateMcpServerRequest): Promise { + async updateMcpServer(request: McpServerUpdateRequest): Promise { return this.call( teamsServicePath('updateMcpServer'), - UpdateMcpServerRequestSchema, + McpServerUpdateRequestSchema, request, 'updateMcpServer', ); } async deleteMcpServer(request: DeleteMcpServerRequest): Promise { - await this.call( + await this.call( teamsServicePath('deleteMcpServer'), DeleteMcpServerRequestSchema, request, @@ -284,7 +268,7 @@ export class TeamsGrpcClient { async listWorkspaceConfigurations( request: ListWorkspaceConfigurationsRequest, - ): Promise { + ): Promise { return this.call( teamsServicePath('listWorkspaceConfigurations'), ListWorkspaceConfigurationsRequestSchema, @@ -294,11 +278,11 @@ export class TeamsGrpcClient { } async createWorkspaceConfiguration( - request: CreateWorkspaceConfigurationRequest, - ): Promise { + request: WorkspaceConfigurationCreateRequest, + ): Promise { return this.call( teamsServicePath('createWorkspaceConfiguration'), - CreateWorkspaceConfigurationRequestSchema, + WorkspaceConfigurationCreateRequestSchema, request, 'createWorkspaceConfiguration', ); @@ -306,7 +290,7 @@ export class TeamsGrpcClient { async getWorkspaceConfiguration( request: GetWorkspaceConfigurationRequest, - ): Promise { + ): Promise { return this.call( teamsServicePath('getWorkspaceConfiguration'), GetWorkspaceConfigurationRequestSchema, @@ -316,18 +300,18 @@ export class TeamsGrpcClient { } async updateWorkspaceConfiguration( - request: UpdateWorkspaceConfigurationRequest, - ): Promise { + request: WorkspaceConfigurationUpdateRequest, + ): Promise { return this.call( teamsServicePath('updateWorkspaceConfiguration'), - UpdateWorkspaceConfigurationRequestSchema, + WorkspaceConfigurationUpdateRequestSchema, request, 'updateWorkspaceConfiguration', ); } async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { - await this.call( + await this.call( teamsServicePath('deleteWorkspaceConfiguration'), DeleteWorkspaceConfigurationRequestSchema, request, @@ -335,7 +319,7 @@ export class TeamsGrpcClient { ); } - async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { + async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { return this.call( teamsServicePath('listMemoryBuckets'), ListMemoryBucketsRequestSchema, @@ -344,16 +328,16 @@ export class TeamsGrpcClient { ); } - async createMemoryBucket(request: CreateMemoryBucketRequest): Promise { + async createMemoryBucket(request: MemoryBucketCreateRequest): Promise { return this.call( teamsServicePath('createMemoryBucket'), - CreateMemoryBucketRequestSchema, + MemoryBucketCreateRequestSchema, request, 'createMemoryBucket', ); } - async getMemoryBucket(request: GetMemoryBucketRequest): Promise { + async getMemoryBucket(request: GetMemoryBucketRequest): Promise { return this.call( teamsServicePath('getMemoryBucket'), GetMemoryBucketRequestSchema, @@ -362,17 +346,17 @@ export class TeamsGrpcClient { ); } - async updateMemoryBucket(request: UpdateMemoryBucketRequest): Promise { + async updateMemoryBucket(request: MemoryBucketUpdateRequest): Promise { return this.call( teamsServicePath('updateMemoryBucket'), - UpdateMemoryBucketRequestSchema, + MemoryBucketUpdateRequestSchema, request, 'updateMemoryBucket', ); } async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { - await this.call( + await this.call( teamsServicePath('deleteMemoryBucket'), DeleteMemoryBucketRequestSchema, request, @@ -380,7 +364,7 @@ export class TeamsGrpcClient { ); } - async listAttachments(request: ListAttachmentsRequest): Promise { + async listAttachments(request: ListAttachmentsRequest): Promise { return this.call( teamsServicePath('listAttachments'), ListAttachmentsRequestSchema, @@ -389,17 +373,17 @@ export class TeamsGrpcClient { ); } - async createAttachment(request: CreateAttachmentRequest): Promise { + async createAttachment(request: AttachmentCreateRequest): Promise { return this.call( teamsServicePath('createAttachment'), - CreateAttachmentRequestSchema, + AttachmentCreateRequestSchema, request, 'createAttachment', ); } async deleteAttachment(request: DeleteAttachmentRequest): Promise { - await this.call( + await this.call( teamsServicePath('deleteAttachment'), DeleteAttachmentRequestSchema, request, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99efec321..5982db56c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@fastify/websocket': specifier: ^11.2.0 version: 11.2.0 + '@grpc/grpc-js': + specifier: ^1.11.1 + version: 1.14.0 '@langchain/core': specifier: 1.0.1 version: 1.0.1(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.6.0(ws@8.18.3)(zod@4.1.12)) From 779ececdfd3364f6171fcae325042c441373b441 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 12 Mar 2026 22:13:34 +0000 Subject: [PATCH 14/43] chore(storybook): drop removed graph story --- .../stories/pages/AgentsGraph.stories.tsx | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 packages/platform-ui/stories/pages/AgentsGraph.stories.tsx diff --git a/packages/platform-ui/stories/pages/AgentsGraph.stories.tsx b/packages/platform-ui/stories/pages/AgentsGraph.stories.tsx deleted file mode 100644 index 181aba579..000000000 --- a/packages/platform-ui/stories/pages/AgentsGraph.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { AgentsGraphContainer } from '@/features/graph/containers/AgentsGraphContainer'; -import { pageHandlers } from '../../.storybook/msw-handlers'; -import { withMainLayout } from '../decorators/withMainLayout'; - -const meta: Meta = { - title: 'Pages/AgentsGraph', - component: AgentsGraphContainer, - decorators: [withMainLayout], - tags: ['!autodocs'], - parameters: { - layout: 'fullscreen', - screen: { - routePath: '/agents/graph', - initialEntry: '/agents/graph', - }, - selectedMenuItem: 'graph', - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - parameters: { - msw: { - handlers: pageHandlers, - }, - }, -}; From eef20bcfcd453395d1aabd13b07468c322e11f4c Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Thu, 12 Mar 2026 11:41:57 +0000 Subject: [PATCH 15/43] chore(platform-server): update devspace (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(platform-server): update devspace * fix(platform-server): align devspace workflow * fix(platform-server): move devspace ports * fix(devspace): sync to /opt/app/data to avoid CRI permission issue The container runtime creates /opt/app/data/workspace as root:node 2755 which is not writable by UID 1000. Using the emptyDir mount root /opt/app/data directly resolves this since Kubernetes sets it to 2777 with fsGroup. * fix(devspace): bootstrap pnpm via corepack The current platform-server dev image does not ship with pnpm shims on PATH. Install the shims under /opt/app/data so the non-root dev container can run pnpm during startup. * fix(devspace): bootstrap buf and graph path * chore(devspace): extract startup script * fix(devspace): add transitional pnpm fallback for stale dev image Until the dev image is rebuilt from the updated Dockerfile.dev, corepack's pnpm shim isn't on PATH. This adds a conditional fallback that will be a no-op once the image includes PNPM_HOME. * fix(devspace): use corepack shims for pnpm Avoid writing to /usr/local/bin when pnpm is missing on PATH. This prepends corepack's shims directory so stale dev images can activate pnpm without permission errors. * fix(devspace): remove transitional fallbacks, build image locally for testing * fix(devspace): use tsx watch for hot reload in dev mode * docs(devspace): fix startup step 5 to match tsx watch command * docs(devspace): remove KUBECONFIG export — apply.sh merges into ~/.kube * chore(devspace): use ghcr.io/agynio/devcontainer-node:1 for dev image Extract the dev container image to its own repository (agynio/devcontainer-node) with proper versioning and CI. Remove Dockerfile.dev and the platform-server-dev-image workflow from this repo. * fix(platform-server): stabilize litellm aliases * fix(platform-server): patch devspace in place * fix(platform): stabilize exec completion - poll exec inspect to finish gRPC streams - add exec inspect helper for docker runner - align devspace patch indentation * revert: remove out-of-scope docker-runner changes * fix: stabilize exec polling and litellm * revert: remove out-of-scope docker-runner changes (again) * fix(platform-server): avoid ncps init block * fix(platform-server): restore ncps init Add LiteLLM provisioner integration coverage. Update LiteLLM test script and helpers. * test(platform-server): add litellm error paths Add mocked provisioner error path coverage and fix key store delete in integration test. * Add .devspace to gitignore --------- Co-authored-by: Vitalii Valkov --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index a11e4d963..3434c50df 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -17,9 +17,6 @@ const trackedEnvKeys = [ 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', - 'TEAMS_SERVICE_ADDR', - 'DEPLOYMENT_ID', - 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From 69674441c2c3a12d9dd88a539525cb08ec5717ae Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 16/43] feat(platform-server): add teams grpc client --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 3434c50df..2b37345d9 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -17,6 +17,7 @@ const trackedEnvKeys = [ 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From e88f9aead977fc8ae66cf8144ee4985953440307 Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Thu, 12 Mar 2026 11:41:57 +0000 Subject: [PATCH 17/43] chore(platform-server): update devspace (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(platform-server): update devspace * fix(platform-server): align devspace workflow * fix(platform-server): move devspace ports * fix(devspace): sync to /opt/app/data to avoid CRI permission issue The container runtime creates /opt/app/data/workspace as root:node 2755 which is not writable by UID 1000. Using the emptyDir mount root /opt/app/data directly resolves this since Kubernetes sets it to 2777 with fsGroup. * fix(devspace): bootstrap pnpm via corepack The current platform-server dev image does not ship with pnpm shims on PATH. Install the shims under /opt/app/data so the non-root dev container can run pnpm during startup. * fix(devspace): bootstrap buf and graph path * chore(devspace): extract startup script * fix(devspace): add transitional pnpm fallback for stale dev image Until the dev image is rebuilt from the updated Dockerfile.dev, corepack's pnpm shim isn't on PATH. This adds a conditional fallback that will be a no-op once the image includes PNPM_HOME. * fix(devspace): use corepack shims for pnpm Avoid writing to /usr/local/bin when pnpm is missing on PATH. This prepends corepack's shims directory so stale dev images can activate pnpm without permission errors. * fix(devspace): remove transitional fallbacks, build image locally for testing * fix(devspace): use tsx watch for hot reload in dev mode * docs(devspace): fix startup step 5 to match tsx watch command * docs(devspace): remove KUBECONFIG export — apply.sh merges into ~/.kube * chore(devspace): use ghcr.io/agynio/devcontainer-node:1 for dev image Extract the dev container image to its own repository (agynio/devcontainer-node) with proper versioning and CI. Remove Dockerfile.dev and the platform-server-dev-image workflow from this repo. * fix(platform-server): stabilize litellm aliases * fix(platform-server): patch devspace in place * fix(platform): stabilize exec completion - poll exec inspect to finish gRPC streams - add exec inspect helper for docker runner - align devspace patch indentation * revert: remove out-of-scope docker-runner changes * fix: stabilize exec polling and litellm * revert: remove out-of-scope docker-runner changes (again) * fix(platform-server): avoid ncps init block * fix(platform-server): restore ncps init Add LiteLLM provisioner integration coverage. Update LiteLLM test script and helpers. * test(platform-server): add litellm error paths Add mocked provisioner error path coverage and fix key store delete in integration test. * Add .devspace to gitignore --------- Co-authored-by: Vitalii Valkov --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 2b37345d9..f84cd3485 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -18,6 +18,7 @@ const trackedEnvKeys = [ 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', + 'DEPLOYMENT_ID', 'NODE_ENV', 'HOSTNAME', ]; From de61dcf864fa5cbca611356b982f2c0c123c1ee7 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 18/43] feat(platform-server): add teams grpc client --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index f84cd3485..a11e4d963 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -19,6 +19,7 @@ const trackedEnvKeys = [ 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From c9794e9c30ccefa1bead0f6680ee5acd00245f90 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 12 Mar 2026 23:35:48 +0000 Subject: [PATCH 19/43] fix(platform-ui): align team api contracts --- .../platform-ui/src/api/modules/teamApi.ts | 305 +++++++--- packages/platform-ui/src/api/types/team.ts | 199 +++--- .../api/__tests__/teamEntities.test.ts | 354 +++++++++++ .../src/features/entities/api/teamEntities.ts | 569 +++++------------- .../entities/hooks/useTeamEntities.ts | 46 +- packages/platform-ui/src/utils/typeGuards.ts | 22 + 6 files changed, 879 insertions(+), 616 deletions(-) create mode 100644 packages/platform-ui/src/features/entities/api/__tests__/teamEntities.test.ts create mode 100644 packages/platform-ui/src/utils/typeGuards.ts diff --git a/packages/platform-ui/src/api/modules/teamApi.ts b/packages/platform-ui/src/api/modules/teamApi.ts index b2b39fc1e..1b0795bb7 100644 --- a/packages/platform-ui/src/api/modules/teamApi.ts +++ b/packages/platform-ui/src/api/modules/teamApi.ts @@ -1,137 +1,243 @@ import { http } from '../http'; import type { TeamAgent, + TeamAgentCreateRequest, + TeamAgentUpdateRequest, TeamAttachment, + TeamAttachmentCreateRequest, + TeamAttachmentKind, + TeamEntityType, TeamListResponse, TeamMemoryBucket, + TeamMemoryBucketCreateRequest, + TeamMemoryBucketUpdateRequest, TeamMcpServer, + TeamMcpServerCreateRequest, + TeamMcpServerUpdateRequest, TeamTool, + TeamToolCreateRequest, + TeamToolType, + TeamToolUpdateRequest, TeamWorkspaceConfiguration, + TeamWorkspaceConfigurationCreateRequest, + TeamWorkspaceConfigurationUpdateRequest, } from '../types/team'; +import { isRecord, readNumber, readString } from '@/utils/typeGuards'; const TEAM_API_PREFIX = '/apiv2/team/v1'; const DEFAULT_PAGE_SIZE = 200; export type TeamListParams = { - pageToken?: string; - pageSize?: number; + page?: number; + perPage?: number; + q?: string; }; type PageInfo = { - nextPageToken?: string; - page?: number; - perPage?: number; - total?: number; + page: number; + perPage: number; + total: number; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const TOOL_TYPES = new Set([ + 'manage', + 'memory', + 'shell_command', + 'send_message', + 'send_slack_message', + 'remind_me', + 'github_clone_repo', + 'call_agent', +]); + +const ATTACHMENT_KINDS = new Set([ + 'agent_tool', + 'agent_memoryBucket', + 'agent_workspaceConfiguration', + 'agent_mcpServer', + 'mcpServer_workspaceConfiguration', +]); + +const ENTITY_TYPES = new Set([ + 'agent', + 'tool', + 'mcpServer', + 'workspaceConfiguration', + 'memoryBucket', +]); + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`Unexpected ${label} response`); + } + return value; } -function readString(value: unknown): string | undefined { - if (typeof value === 'string' && value.trim().length > 0) { - return value; +function readRequiredString(record: Record, key: string, label: string): string { + const value = readString(record[key]); + if (!value) { + throw new Error(`Unexpected ${label} response`); } - return undefined; + return value; } -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; +function readRequiredRecord(record: Record, key: string, label: string): Record { + const value = record[key]; + if (!isRecord(value)) { + throw new Error(`Unexpected ${label} response`); } - return undefined; + return value; } -function readPageInfo(record: Record): PageInfo { - const nextPageToken = readString(record.nextPageToken ?? record.next_page_token); - const page = readNumber(record.page ?? record.pageNumber ?? record.page_number); - const perPage = readNumber(record.perPage ?? record.per_page); - const total = readNumber(record.total); - return { nextPageToken, page, perPage, total }; +function readEnumValue(value: unknown, allowed: ReadonlySet, label: string): T { + const trimmed = readString(value); + if (!trimmed || !allowed.has(trimmed as T)) { + throw new Error(`Unexpected ${label} response`); + } + return trimmed as T; } -function getItems(record: Record, key: string): T[] { - const raw = record[key] ?? record.items; - return Array.isArray(raw) ? (raw as T[]) : []; +function readPageInfo(record: Record): PageInfo { + const page = readNumber(record.page); + const perPage = readNumber(record.perPage); + const total = readNumber(record.total); + if (page === undefined || perPage === undefined || total === undefined) { + throw new Error('Unexpected list response'); + } + return { page, perPage, total }; } -function parseListResponse(payload: unknown, key: string): TeamListResponse { - if (!isRecord(payload)) { - return { items: [] }; +function parseListResponse(payload: unknown, parseItem: (item: unknown) => T): TeamListResponse { + const record = requireRecord(payload, 'list'); + const items = record.items; + if (!Array.isArray(items)) { + throw new Error('Unexpected list response'); } - const items = getItems(payload, key); - const pageInfo = readPageInfo(payload); - return { items, ...pageInfo }; + const pageInfo = readPageInfo(record); + return { items: items.map((item) => parseItem(item)), ...pageInfo }; } function buildListParams(params?: TeamListParams): Record { if (!params) return {}; const result: Record = {}; - if (params.pageSize !== undefined) { - result.pageSize = params.pageSize; - result.page_size = params.pageSize; - result.per_page = params.pageSize; - } - if (params.pageToken !== undefined) { - result.pageToken = params.pageToken; - result.page_token = params.pageToken; - const parsed = Number(params.pageToken); - if (Number.isFinite(parsed)) { - result.page = parsed; - } - } + if (typeof params.page === 'number') result.page = params.page; + if (typeof params.perPage === 'number') result.perPage = params.perPage; + const query = readString(params.q); + if (query) result.q = query; return result; } -function resolveNextPageToken(pageInfo: PageInfo, pageSize: number, currentPage: number, count: number): string | undefined { - if (pageInfo.nextPageToken) return pageInfo.nextPageToken; - if (pageInfo.page !== undefined && pageInfo.perPage !== undefined && pageInfo.total !== undefined) { - if (pageInfo.page * pageInfo.perPage >= pageInfo.total) { - return undefined; - } - return String(pageInfo.page + 1); - } - if (count < pageSize) { - return undefined; - } - return String(currentPage + 1); +function parseEntityMeta(record: Record, label: string): { id: string; createdAt: string; updatedAt: string } { + return { + id: readRequiredString(record, 'id', label), + createdAt: readRequiredString(record, 'createdAt', label), + updatedAt: readRequiredString(record, 'updatedAt', label), + }; +} + +function parseAgent(raw: unknown): TeamAgent { + const record = requireRecord(raw, 'agent'); + const meta = parseEntityMeta(record, 'agent'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'agent'), + }; +} + +function parseTool(raw: unknown): TeamTool { + const record = requireRecord(raw, 'tool'); + const meta = parseEntityMeta(record, 'tool'); + return { + ...meta, + type: readEnumValue(record.type, TOOL_TYPES, 'tool'), + name: readString(record.name), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'tool'), + }; +} + +function parseMcpServer(raw: unknown): TeamMcpServer { + const record = requireRecord(raw, 'mcp server'); + const meta = parseEntityMeta(record, 'mcp server'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'mcp server'), + }; +} + +function parseWorkspaceConfiguration(raw: unknown): TeamWorkspaceConfiguration { + const record = requireRecord(raw, 'workspace configuration'); + const meta = parseEntityMeta(record, 'workspace configuration'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'workspace configuration'), + }; +} + +function parseMemoryBucket(raw: unknown): TeamMemoryBucket { + const record = requireRecord(raw, 'memory bucket'); + const meta = parseEntityMeta(record, 'memory bucket'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'memory bucket'), + }; +} + +function parseAttachment(raw: unknown): TeamAttachment { + const record = requireRecord(raw, 'attachment'); + const meta = parseEntityMeta(record, 'attachment'); + return { + ...meta, + kind: readEnumValue(record.kind, ATTACHMENT_KINDS, 'attachment kind'), + sourceId: readRequiredString(record, 'sourceId', 'attachment'), + targetId: readRequiredString(record, 'targetId', 'attachment'), + sourceType: readEnumValue(record.sourceType, ENTITY_TYPES, 'attachment sourceType'), + targetType: readEnumValue(record.targetType, ENTITY_TYPES, 'attachment targetType'), + }; } async function listAllPages( fetchPage: (params: TeamListParams) => Promise>, - pageSize = DEFAULT_PAGE_SIZE, + params?: TeamListParams, ): Promise { const items: T[] = []; - let pageToken: string | undefined = undefined; - let pageIndex = 1; + let page = params?.page ?? 1; + const perPage = params?.perPage ?? DEFAULT_PAGE_SIZE; + const q = params?.q; for (let i = 0; i < 50; i += 1) { - const response = await fetchPage({ pageSize, pageToken }); + const response = await fetchPage({ page, perPage, q }); items.push(...response.items); - const nextToken = resolveNextPageToken(response, pageSize, pageIndex, response.items.length); - if (!nextToken) break; - pageToken = nextToken; - pageIndex += 1; + if (response.page * response.perPage >= response.total) break; + page = response.page + 1; } return items; } export async function listAgents(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/agents`, { params: buildListParams(params) }); - return parseListResponse(payload, 'agents'); + return parseListResponse(payload, parseAgent); } export async function listAllAgents(): Promise { return listAllPages(listAgents); } -export async function createAgent(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/agents`, payload); +export async function createAgent(payload: TeamAgentCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/agents`, payload); + return parseAgent(response); } -export async function updateAgent(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); +export async function updateAgent(id: string, payload: TeamAgentUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); + return parseAgent(response); } export async function deleteAgent(id: string): Promise { @@ -140,19 +246,21 @@ export async function deleteAgent(id: string): Promise { export async function listTools(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/tools`, { params: buildListParams(params) }); - return parseListResponse(payload, 'tools'); + return parseListResponse(payload, parseTool); } export async function listAllTools(): Promise { return listAllPages(listTools); } -export async function createTool(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/tools`, payload); +export async function createTool(payload: TeamToolCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/tools`, payload); + return parseTool(response); } -export async function updateTool(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); +export async function updateTool(id: string, payload: TeamToolUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); + return parseTool(response); } export async function deleteTool(id: string): Promise { @@ -161,19 +269,21 @@ export async function deleteTool(id: string): Promise { export async function listMcpServers(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/mcp-servers`, { params: buildListParams(params) }); - return parseListResponse(payload, 'mcpServers'); + return parseListResponse(payload, parseMcpServer); } export async function listAllMcpServers(): Promise { return listAllPages(listMcpServers); } -export async function createMcpServer(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); +export async function createMcpServer(payload: TeamMcpServerCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); + return parseMcpServer(response); } -export async function updateMcpServer(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); +export async function updateMcpServer(id: string, payload: TeamMcpServerUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); + return parseMcpServer(response); } export async function deleteMcpServer(id: string): Promise { @@ -186,22 +296,26 @@ export async function listWorkspaceConfigurations( const payload = await http.get(`${TEAM_API_PREFIX}/workspace-configurations`, { params: buildListParams(params), }); - return parseListResponse(payload, 'workspaceConfigurations'); + return parseListResponse(payload, parseWorkspaceConfiguration); } export async function listAllWorkspaceConfigurations(): Promise { return listAllPages(listWorkspaceConfigurations); } -export async function createWorkspaceConfiguration(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); +export async function createWorkspaceConfiguration( + payload: TeamWorkspaceConfigurationCreateRequest, +): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); + return parseWorkspaceConfiguration(response); } export async function updateWorkspaceConfiguration( id: string, - payload: Record, + payload: TeamWorkspaceConfigurationUpdateRequest, ): Promise { - return http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); + const response = await http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); + return parseWorkspaceConfiguration(response); } export async function deleteWorkspaceConfiguration(id: string): Promise { @@ -210,19 +324,21 @@ export async function deleteWorkspaceConfiguration(id: string): Promise { export async function listMemoryBuckets(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/memory-buckets`, { params: buildListParams(params) }); - return parseListResponse(payload, 'memoryBuckets'); + return parseListResponse(payload, parseMemoryBucket); } export async function listAllMemoryBuckets(): Promise { return listAllPages(listMemoryBuckets); } -export async function createMemoryBucket(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); +export async function createMemoryBucket(payload: TeamMemoryBucketCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); + return parseMemoryBucket(response); } -export async function updateMemoryBucket(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); +export async function updateMemoryBucket(id: string, payload: TeamMemoryBucketUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); + return parseMemoryBucket(response); } export async function deleteMemoryBucket(id: string): Promise { @@ -231,15 +347,16 @@ export async function deleteMemoryBucket(id: string): Promise { export async function listAttachments(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/attachments`, { params: buildListParams(params) }); - return parseListResponse(payload, 'attachments'); + return parseListResponse(payload, parseAttachment); } export async function listAllAttachments(): Promise { return listAllPages(listAttachments); } -export async function createAttachment(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/attachments`, payload); +export async function createAttachment(payload: TeamAttachmentCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/attachments`, payload); + return parseAttachment(response); } export async function deleteAttachment(id: string): Promise { diff --git a/packages/platform-ui/src/api/types/team.ts b/packages/platform-ui/src/api/types/team.ts index de47505b4..97108c51f 100644 --- a/packages/platform-ui/src/api/types/team.ts +++ b/packages/platform-ui/src/api/types/team.ts @@ -1,116 +1,157 @@ -export type TeamAgentWhenBusy = - | 'AGENT_WHEN_BUSY_UNSPECIFIED' - | 'AGENT_WHEN_BUSY_WAIT' - | 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; +export type TeamAgentWhenBusy = 'wait' | 'injectAfterTools'; -export type TeamAgentProcessBuffer = - | 'AGENT_PROCESS_BUFFER_UNSPECIFIED' - | 'AGENT_PROCESS_BUFFER_ALL_TOGETHER' - | 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; +export type TeamAgentProcessBuffer = 'allTogether' | 'oneByOne'; export type TeamToolType = - | 'TOOL_TYPE_UNSPECIFIED' - | 'TOOL_TYPE_MANAGE' - | 'TOOL_TYPE_MEMORY' - | 'TOOL_TYPE_SHELL_COMMAND' - | 'TOOL_TYPE_SEND_MESSAGE' - | 'TOOL_TYPE_SEND_SLACK_MESSAGE' - | 'TOOL_TYPE_REMIND_ME' - | 'TOOL_TYPE_GITHUB_CLONE_REPO' - | 'TOOL_TYPE_CALL_AGENT'; - -export type TeamWorkspacePlatform = - | 'WORKSPACE_PLATFORM_UNSPECIFIED' - | 'WORKSPACE_PLATFORM_LINUX_AMD64' - | 'WORKSPACE_PLATFORM_LINUX_ARM64' - | 'WORKSPACE_PLATFORM_AUTO'; - -export type TeamMemoryBucketScope = - | 'MEMORY_BUCKET_SCOPE_UNSPECIFIED' - | 'MEMORY_BUCKET_SCOPE_GLOBAL' - | 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - -export type TeamEntityType = - | 'ENTITY_TYPE_UNSPECIFIED' - | 'ENTITY_TYPE_AGENT' - | 'ENTITY_TYPE_TOOL' - | 'ENTITY_TYPE_MCP_SERVER' - | 'ENTITY_TYPE_WORKSPACE_CONFIGURATION' - | 'ENTITY_TYPE_MEMORY_BUCKET'; + | 'manage' + | 'memory' + | 'shell_command' + | 'send_message' + | 'send_slack_message' + | 'remind_me' + | 'github_clone_repo' + | 'call_agent'; + +export type TeamWorkspacePlatform = 'linux/amd64' | 'linux/arm64' | 'auto'; + +export type TeamMemoryBucketScope = 'global' | 'perThread'; + +export type TeamEntityType = 'agent' | 'tool' | 'mcpServer' | 'workspaceConfiguration' | 'memoryBucket'; export type TeamAttachmentKind = - | 'ATTACHMENT_KIND_UNSPECIFIED' - | 'ATTACHMENT_KIND_AGENT_TOOL' - | 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET' - | 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION' - | 'ATTACHMENT_KIND_AGENT_MCP_SERVER' - | 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION'; + | 'agent_tool' + | 'agent_memoryBucket' + | 'agent_workspaceConfiguration' + | 'agent_mcpServer' + | 'mcpServer_workspaceConfiguration'; export interface TeamListResponse { items: T[]; - nextPageToken?: string; - page?: number; - perPage?: number; - total?: number; + page: number; + perPage: number; + total: number; } export interface TeamAgent { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamTool { - id?: string; - createdAt?: string; - updatedAt?: string; - type?: TeamToolType | string | number; + id: string; + createdAt: string; + updatedAt: string; + type: TeamToolType; name?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamMcpServer { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamWorkspaceConfiguration { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamMemoryBucket { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamAttachment { - id?: string; - kind?: TeamAttachmentKind | string | number; - sourceId?: string; - targetId?: string; - sourceType?: TeamEntityType | string | number; - targetType?: TeamEntityType | string | number; - meta?: { id?: string }; + id: string; + createdAt: string; + updatedAt: string; + kind: TeamAttachmentKind; + sourceId: string; + targetId: string; + sourceType: TeamEntityType; + targetType: TeamEntityType; +} + +export interface TeamAgentCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamAgentUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamToolCreateRequest { + type: TeamToolType; + name?: string; + description?: string; + config?: Record; +} + +export interface TeamToolUpdateRequest { + name?: string; + description?: string; + config?: Record; +} + +export interface TeamMcpServerCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamMcpServerUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamWorkspaceConfigurationCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamWorkspaceConfigurationUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamMemoryBucketCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamMemoryBucketUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamAttachmentCreateRequest { + kind: TeamAttachmentKind; + sourceId: string; + targetId: string; } diff --git a/packages/platform-ui/src/features/entities/api/__tests__/teamEntities.test.ts b/packages/platform-ui/src/features/entities/api/__tests__/teamEntities.test.ts new file mode 100644 index 000000000..60c01307b --- /dev/null +++ b/packages/platform-ui/src/features/entities/api/__tests__/teamEntities.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; + +import type { TemplateSchema } from '@/api/types/graph'; +import type { TeamAgent, TeamAttachment, TeamMemoryBucket, TeamTool, TeamWorkspaceConfiguration } from '@/api/types/team'; +import { + TEAM_ATTACHMENT_KIND, + buildAgentRequest, + buildMcpServerRequest, + buildMemoryBucketRequest, + buildToolRequest, + buildWorkspaceRequest, + diffTeamAttachments, + mapTeamEntities, + sanitizeConfigForPersistence, +} from '@/features/entities/api/teamEntities'; +import type { GraphEntityUpsertInput } from '@/features/entities/types'; + +const BASE_META = { + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +const templates: TemplateSchema[] = [ + { + name: 'agent', + title: 'Agent', + kind: 'agent', + sourcePorts: [], + targetPorts: [], + }, + { + name: 'manageTool', + title: 'Manage Tool', + kind: 'tool', + sourcePorts: [], + targetPorts: [], + }, + { + name: 'workspace', + title: 'Workspace', + kind: 'service', + sourcePorts: [], + targetPorts: [], + }, + { + name: 'memory', + title: 'Memory', + kind: 'service', + sourcePorts: [], + targetPorts: [], + }, +]; + +describe('teamEntities mapping', () => { + it('maps team entities to graph summaries with camelCase configs', () => { + const agent: TeamAgent = { + id: 'agent-1', + title: 'Ops Agent', + description: 'Primary responder', + config: { + model: 'gpt-4', + systemPrompt: 'Be precise.', + debounceMs: 250, + whenBusy: 'wait', + processBuffer: 'oneByOne', + sendFinalResponseToThread: true, + summarizationKeepTokens: 120, + summarizationMaxTokens: 360, + restrictOutput: true, + restrictionMessage: 'No secrets.', + restrictionMaxInjections: 2, + name: 'Alpha', + role: 'Navigator', + }, + ...BASE_META, + }; + + const tool: TeamTool = { + id: 'tool-1', + type: 'manage', + name: 'manage_team', + description: 'Manage tool', + config: {}, + ...BASE_META, + }; + + const workspace: TeamWorkspaceConfiguration = { + id: 'workspace-1', + title: 'Workspace', + description: 'Default workspace', + config: { + image: 'docker.io/library/node:18', + cpuLimit: '500m', + memoryLimit: 1024, + platform: 'linux/amd64', + enableDinD: true, + ttlSeconds: 120, + volumes: { enabled: true, mountPath: '/workspace' }, + }, + ...BASE_META, + }; + + const memory: TeamMemoryBucket = { + id: 'memory-1', + title: 'Memory', + description: 'Shared memory', + config: { scope: 'global', collectionPrefix: 'team' }, + ...BASE_META, + }; + + const entities = mapTeamEntities( + { + agents: [agent], + tools: [tool], + workspaceConfigurations: [workspace], + memoryBuckets: [memory], + }, + templates, + ); + + const agentSummary = entities.find((entry) => entry.entityKind === 'agent'); + expect(agentSummary?.config).toEqual( + expect.objectContaining({ + model: 'gpt-4', + systemPrompt: 'Be precise.', + queue: { + debounceMs: 250, + whenBusy: 'wait', + processBuffer: 'oneByOne', + }, + summarization: { + keepTokens: 120, + maxTokens: 360, + }, + restrictOutput: true, + restrictionMessage: 'No secrets.', + restrictionMaxInjections: 2, + name: 'Alpha', + role: 'Navigator', + }), + ); + + const toolSummary = entities.find((entry) => entry.entityKind === 'tool'); + expect(toolSummary?.templateName).toBe('manageTool'); + expect(toolSummary?.toolType).toBe('manage'); + expect(toolSummary?.config).toEqual(expect.objectContaining({ name: 'manage_team' })); + + const workspaceSummary = entities.find((entry) => entry.entityKind === 'workspace'); + expect(workspaceSummary?.config).toEqual( + expect.objectContaining({ + cpu_limit: '500m', + memory_limit: 1024, + platform: 'linux/amd64', + enableDinD: true, + ttlSeconds: 120, + volumes: { enabled: true, mountPath: '/workspace' }, + }), + ); + }); +}); + +describe('teamEntities request builders', () => { + it('builds agent requests with camelCase config keys', () => { + const input: GraphEntityUpsertInput = { + entityKind: 'agent', + template: 'agent', + title: 'Ops Agent', + config: { + model: 'gpt-4', + systemPrompt: 'Stay focused.', + queue: { debounceMs: 100, whenBusy: 'wait', processBuffer: 'allTogether' }, + sendFinalResponseToThread: true, + summarization: { keepTokens: 50, maxTokens: 200 }, + restrictOutput: true, + restrictionMessage: 'No secrets.', + restrictionMaxInjections: 3, + name: 'Alpha', + role: 'Navigator', + }, + }; + + expect(buildAgentRequest(input)).toEqual({ + title: 'Ops Agent', + description: '', + config: { + model: 'gpt-4', + systemPrompt: 'Stay focused.', + debounceMs: 100, + whenBusy: 'wait', + processBuffer: 'allTogether', + sendFinalResponseToThread: true, + summarizationKeepTokens: 50, + summarizationMaxTokens: 200, + restrictOutput: true, + restrictionMessage: 'No secrets.', + restrictionMaxInjections: 3, + name: 'Alpha', + role: 'Navigator', + }, + }); + }); + + it('builds tool, mcp, workspace, and memory requests', () => { + const toolInput: GraphEntityUpsertInput = { + entityKind: 'tool', + template: 'manageTool', + title: 'Manage tool', + config: { name: 'manage_team' }, + }; + expect(buildToolRequest(toolInput)).toEqual({ + type: 'manage', + name: 'manage_team', + description: 'Manage tool', + config: { name: 'manage_team' }, + }); + + const mcpInput: GraphEntityUpsertInput = { + entityKind: 'mcp', + template: 'mcpServer', + title: 'Filesystem MCP', + config: { + namespace: 'fs', + command: 'fs', + workdir: '/srv', + env: [{ name: 'TOKEN', value: 'abc' }], + requestTimeoutMs: 1000, + startupTimeoutMs: 2000, + heartbeatIntervalMs: 3000, + staleTimeoutMs: 4000, + restart: { maxAttempts: 3, backoffMs: 500 }, + }, + }; + expect(buildMcpServerRequest(mcpInput)).toEqual({ + title: 'Filesystem MCP', + description: '', + config: { + namespace: 'fs', + command: 'fs', + workdir: '/srv', + env: [{ name: 'TOKEN', value: 'abc' }], + requestTimeoutMs: 1000, + startupTimeoutMs: 2000, + heartbeatIntervalMs: 3000, + staleTimeoutMs: 4000, + restart: { maxAttempts: 3, backoffMs: 500 }, + }, + }); + + const workspaceInput: GraphEntityUpsertInput = { + entityKind: 'workspace', + template: 'workspace', + title: 'Workspace', + config: { + image: 'docker.io/library/node:18', + initialScript: 'echo ready', + cpu_limit: '500m', + memory_limit: '1Gi', + platform: 'linux/amd64', + enableDinD: true, + ttlSeconds: 90, + volumes: { enabled: true, mountPath: '/workspace' }, + }, + }; + expect(buildWorkspaceRequest(workspaceInput)).toEqual({ + title: 'Workspace', + description: '', + config: { + image: 'docker.io/library/node:18', + initialScript: 'echo ready', + cpuLimit: '500m', + memoryLimit: '1Gi', + platform: 'linux/amd64', + enableDinD: true, + ttlSeconds: 90, + volumes: { enabled: true, mountPath: '/workspace' }, + }, + }); + + const memoryInput: GraphEntityUpsertInput = { + entityKind: 'memory', + template: 'memory', + title: 'Memory', + config: { scope: 'global', collectionPrefix: 'team' }, + }; + expect(buildMemoryBucketRequest(memoryInput)).toEqual({ + title: 'Memory', + description: '', + config: { scope: 'global', collectionPrefix: 'team' }, + }); + }); +}); + +describe('teamEntities attachment diffing', () => { + it('identifies attachments to create and remove', () => { + const current: TeamAttachment[] = [ + { + id: 'att-1', + kind: TEAM_ATTACHMENT_KIND.agentTool, + sourceId: 'agent-1', + targetId: 'tool-1', + sourceType: 'agent', + targetType: 'tool', + ...BASE_META, + }, + { + id: 'att-2', + kind: TEAM_ATTACHMENT_KIND.agentMemoryBucket, + sourceId: 'agent-1', + targetId: 'memory-1', + sourceType: 'agent', + targetType: 'memoryBucket', + ...BASE_META, + }, + ]; + const desired = [ + { kind: TEAM_ATTACHMENT_KIND.agentTool, sourceId: 'agent-1', targetId: 'tool-1' }, + { kind: TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration, sourceId: 'agent-1', targetId: 'workspace-1' }, + ]; + + const { create, remove } = diffTeamAttachments(current, desired); + expect(create).toEqual([ + { kind: TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration, sourceId: 'agent-1', targetId: 'workspace-1' }, + ]); + expect(remove).toEqual([current[1]]); + }); +}); + +describe('teamEntities config sanitization', () => { + it('strips non-persisted keys and env sources', () => { + const config = { + title: 'Workspace', + template: 'workspace', + kind: 'workspace', + env: [ + { name: 'TOKEN', value: 'abc', source: 'secret', meta: 'keep' }, + { name: 'EMPTY', value: '', source: 'static' }, + ], + nested: { + env: [{ name: 'INNER', value: 'xyz', source: 'variable' }], + }, + count: 2, + optional: undefined, + }; + + expect(sanitizeConfigForPersistence('workspace', config)).toEqual({ + env: [ + { name: 'TOKEN', value: 'abc', meta: 'keep' }, + { name: 'EMPTY', value: '' }, + ], + nested: { env: [{ name: 'INNER', value: 'xyz' }] }, + count: 2, + }); + }); +}); diff --git a/packages/platform-ui/src/features/entities/api/teamEntities.ts b/packages/platform-ui/src/features/entities/api/teamEntities.ts index 563272a4c..919b55aa5 100644 --- a/packages/platform-ui/src/features/entities/api/teamEntities.ts +++ b/packages/platform-ui/src/features/entities/api/teamEntities.ts @@ -1,20 +1,26 @@ import type { TemplateSchema } from '@/api/types/graph'; import type { TeamAgent, + TeamAgentCreateRequest, TeamAttachment, TeamAttachmentKind, TeamMemoryBucket, + TeamMemoryBucketCreateRequest, TeamMemoryBucketScope, TeamMcpServer, + TeamMcpServerCreateRequest, TeamTool, + TeamToolCreateRequest, TeamToolType, TeamWorkspaceConfiguration, + TeamWorkspaceConfigurationCreateRequest, TeamWorkspacePlatform, } from '@/api/types/team'; import type { AgentQueueConfig, NodeConfig } from '@/components/nodeProperties/types'; import { readEnvList, readQueueConfig, readSummarizationConfig } from '@/components/nodeProperties/utils'; import { buildGraphNodeFromTemplate } from '@/features/graph/mappers'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; +import { isRecord, readNumber, readString } from '@/utils/typeGuards'; import type { GraphEdgeFilter, GraphEntityKind, @@ -28,34 +34,33 @@ export const EXCLUDED_WORKSPACE_TEMPLATES = new Set(['memory', 'memoryConnector' export const INCLUDED_MEMORY_TEMPLATES = new Set(['memory']); export const TEAM_ATTACHMENT_KIND = { - agentTool: 'ATTACHMENT_KIND_AGENT_TOOL', - agentMemoryBucket: 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET', - agentWorkspaceConfiguration: 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION', - agentMcpServer: 'ATTACHMENT_KIND_AGENT_MCP_SERVER', - mcpServerWorkspaceConfiguration: 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION', + agentTool: 'agent_tool', + agentMemoryBucket: 'agent_memoryBucket', + agentWorkspaceConfiguration: 'agent_workspaceConfiguration', + agentMcpServer: 'agent_mcpServer', + mcpServerWorkspaceConfiguration: 'mcpServer_workspaceConfiguration', } as const satisfies Record; export const TEAM_TOOL_TYPE = { - manage: 'TOOL_TYPE_MANAGE', - memory: 'TOOL_TYPE_MEMORY', - shellCommand: 'TOOL_TYPE_SHELL_COMMAND', - sendMessage: 'TOOL_TYPE_SEND_MESSAGE', - sendSlackMessage: 'TOOL_TYPE_SEND_SLACK_MESSAGE', - remindMe: 'TOOL_TYPE_REMIND_ME', - githubCloneRepo: 'TOOL_TYPE_GITHUB_CLONE_REPO', - callAgent: 'TOOL_TYPE_CALL_AGENT', + manage: 'manage', + memory: 'memory', + shellCommand: 'shell_command', + sendMessage: 'send_message', + sendSlackMessage: 'send_slack_message', + remindMe: 'remind_me', + githubCloneRepo: 'github_clone_repo', + callAgent: 'call_agent', } as const satisfies Record; const TOOL_TYPE_TO_TEMPLATE: Record = { - TOOL_TYPE_UNSPECIFIED: 'tool', - TOOL_TYPE_MANAGE: 'manageTool', - TOOL_TYPE_MEMORY: 'memoryTool', - TOOL_TYPE_SHELL_COMMAND: 'shellTool', - TOOL_TYPE_SEND_MESSAGE: 'sendMessageTool', - TOOL_TYPE_SEND_SLACK_MESSAGE: 'sendSlackMessageTool', - TOOL_TYPE_REMIND_ME: 'remindMeTool', - TOOL_TYPE_GITHUB_CLONE_REPO: 'githubCloneRepoTool', - TOOL_TYPE_CALL_AGENT: 'callAgentTool', + manage: 'manageTool', + memory: 'memoryTool', + shell_command: 'shellTool', + send_message: 'sendMessageTool', + send_slack_message: 'sendSlackMessageTool', + remind_me: 'remindMeTool', + github_clone_repo: 'githubCloneRepoTool', + call_agent: 'callAgentTool', }; const TEMPLATE_TO_TOOL_TYPE: Record = Object.entries(TOOL_TYPE_TO_TEMPLATE).reduce( @@ -76,12 +81,11 @@ const ENTITY_KIND_TO_NODE_KIND: Record }; const ATTACHMENT_KIND_HANDLES: Record = { - ATTACHMENT_KIND_UNSPECIFIED: { sourceHandle: '$self', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_TOOL: { sourceHandle: 'tools', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_MEMORY_BUCKET: { sourceHandle: 'memory', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_MCP_SERVER: { sourceHandle: 'mcp', targetHandle: '$self' }, - ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, + agent_tool: { sourceHandle: 'tools', targetHandle: '$self' }, + agent_memoryBucket: { sourceHandle: 'memory', targetHandle: '$self' }, + agent_workspaceConfiguration: { sourceHandle: 'workspace', targetHandle: '$self' }, + agent_mcpServer: { sourceHandle: 'mcp', targetHandle: '$self' }, + mcpServer_workspaceConfiguration: { sourceHandle: 'workspace', targetHandle: '$self' }, }; export type TeamAttachmentInput = { @@ -90,17 +94,6 @@ export type TeamAttachmentInput = { targetId: string; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - return undefined; -} - function readOptionalString(value: unknown): string | undefined { if (typeof value === 'string') { return value; @@ -108,15 +101,6 @@ function readOptionalString(value: unknown): string | undefined { return undefined; } -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -} - function readBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') return value; if (typeof value === 'string') { @@ -126,256 +110,51 @@ function readBoolean(value: unknown): boolean | undefined { return undefined; } -function readField(record: Record, keys: string[], reader: (value: unknown) => T | undefined): T | undefined { - for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(record, key)) continue; - const value = reader(record[key]); - if (value !== undefined) return value; - } - return undefined; -} - -function normalizeEnumName(value: unknown): string { - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - if (typeof value === 'string') { - return value.trim().toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); - } - return ''; -} - -function normalizeTeamToolType(value: unknown): TeamToolType | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return TEAM_TOOL_TYPE.manage; - case 2: - return TEAM_TOOL_TYPE.memory; - case 3: - return TEAM_TOOL_TYPE.shellCommand; - case 4: - return TEAM_TOOL_TYPE.sendMessage; - case 5: - return TEAM_TOOL_TYPE.sendSlackMessage; - case 6: - return TEAM_TOOL_TYPE.remindMe; - case 7: - return TEAM_TOOL_TYPE.githubCloneRepo; - case 8: - return TEAM_TOOL_TYPE.callAgent; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('TOOL_TYPE_')) { - return normalized as TeamToolType; - } - switch (normalized) { - case 'MANAGE': - return TEAM_TOOL_TYPE.manage; - case 'MEMORY': - return TEAM_TOOL_TYPE.memory; - case 'SHELL_COMMAND': - case 'SHELL': - return TEAM_TOOL_TYPE.shellCommand; - case 'SEND_MESSAGE': - return TEAM_TOOL_TYPE.sendMessage; - case 'SEND_SLACK_MESSAGE': - return TEAM_TOOL_TYPE.sendSlackMessage; - case 'REMIND_ME': - return TEAM_TOOL_TYPE.remindMe; - case 'GITHUB_CLONE_REPO': - return TEAM_TOOL_TYPE.githubCloneRepo; - case 'CALL_AGENT': - return TEAM_TOOL_TYPE.callAgent; - default: - return undefined; - } -} - -function normalizeAttachmentKind(value: unknown): TeamAttachmentKind | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return TEAM_ATTACHMENT_KIND.agentTool; - case 2: - return TEAM_ATTACHMENT_KIND.agentMemoryBucket; - case 3: - return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; - case 4: - return TEAM_ATTACHMENT_KIND.agentMcpServer; - case 5: - return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('ATTACHMENT_KIND_')) { - if (normalized === 'ATTACHMENT_KIND_UNSPECIFIED') return undefined; - return normalized as TeamAttachmentKind; - } - switch (normalized) { - case 'AGENT_TOOL': - return TEAM_ATTACHMENT_KIND.agentTool; - case 'AGENT_MEMORY_BUCKET': - return TEAM_ATTACHMENT_KIND.agentMemoryBucket; - case 'AGENT_WORKSPACE_CONFIGURATION': - return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; - case 'AGENT_MCP_SERVER': - return TEAM_ATTACHMENT_KIND.agentMcpServer; - case 'MCP_SERVER_WORKSPACE_CONFIGURATION': - return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; - default: - return undefined; - } -} - -function normalizeWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return 'WORKSPACE_PLATFORM_LINUX_AMD64'; - case 2: - return 'WORKSPACE_PLATFORM_LINUX_ARM64'; - case 3: - return 'WORKSPACE_PLATFORM_AUTO'; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('WORKSPACE_PLATFORM_')) { - return normalized as TeamWorkspacePlatform; - } - switch (normalized) { - case 'LINUX_AMD64': - case 'LINUX_AMD_64': - case 'LINUX/AMD64': - return 'WORKSPACE_PLATFORM_LINUX_AMD64'; - case 'LINUX_ARM64': - case 'LINUX_ARM_64': - case 'LINUX/ARM64': - return 'WORKSPACE_PLATFORM_LINUX_ARM64'; - case 'AUTO': - return 'WORKSPACE_PLATFORM_AUTO'; - default: - return undefined; - } -} - -function normalizeMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return 'MEMORY_BUCKET_SCOPE_GLOBAL'; - case 2: - return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('MEMORY_BUCKET_SCOPE_')) { - return normalized as TeamMemoryBucketScope; - } - switch (normalized) { - case 'GLOBAL': - return 'MEMORY_BUCKET_SCOPE_GLOBAL'; - case 'PER_THREAD': - case 'PERTHREAD': - return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - default: - return undefined; - } -} - type AgentQueueWhenBusy = NonNullable; type AgentQueueProcessBuffer = NonNullable; +const QUEUE_WHEN_BUSY_VALUES: AgentQueueWhenBusy[] = ['wait', 'injectAfterTools']; +const QUEUE_PROCESS_BUFFER_VALUES: AgentQueueProcessBuffer[] = ['allTogether', 'oneByOne']; +const WORKSPACE_PLATFORM_VALUES: TeamWorkspacePlatform[] = ['linux/amd64', 'linux/arm64', 'auto']; +const MEMORY_SCOPE_VALUES: TeamMemoryBucketScope[] = ['global', 'perThread']; -function parseWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { - if (typeof value === 'number') { - if (value === 1) return 'wait'; - if (value === 2) return 'injectAfterTools'; - } - const normalized = normalizeEnumName(value); - if (normalized.includes('WAIT')) return 'wait'; - if (normalized.includes('INJECT_AFTER_TOOLS')) return 'injectAfterTools'; - return undefined; -} - -function parseProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { - if (typeof value === 'number') { - if (value === 1) return 'allTogether'; - if (value === 2) return 'oneByOne'; - } - const normalized = normalizeEnumName(value); - if (normalized.includes('ALL_TOGETHER')) return 'allTogether'; - if (normalized.includes('ONE_BY_ONE')) return 'oneByOne'; - return undefined; -} - -function mapWorkspacePlatformToUi(value: unknown): string | undefined { - const normalized = normalizeWorkspacePlatform(value); - switch (normalized) { - case 'WORKSPACE_PLATFORM_LINUX_AMD64': - return 'linux/amd64'; - case 'WORKSPACE_PLATFORM_LINUX_ARM64': - return 'linux/arm64'; - case 'WORKSPACE_PLATFORM_AUTO': - return 'auto'; - default: - return undefined; +function readEnumValue(value: unknown, allowed: readonly T[], label: string): T | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') { + throw new Error(`Unexpected ${label} value`); } -} - -function mapWorkspacePlatformToTeam(value: unknown): TeamWorkspacePlatform | undefined { - if (typeof value === 'string') { - const trimmed = value.trim().toLowerCase(); - if (trimmed === 'linux/amd64') return 'WORKSPACE_PLATFORM_LINUX_AMD64'; - if (trimmed === 'linux/arm64') return 'WORKSPACE_PLATFORM_LINUX_ARM64'; - if (trimmed === 'auto') return 'WORKSPACE_PLATFORM_AUTO'; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!allowed.includes(trimmed as T)) { + throw new Error(`Unexpected ${label} value`); } - return normalizeWorkspacePlatform(value); + return trimmed as T; } -function mapMemoryScopeToUi(value: unknown): string | undefined { - const normalized = normalizeMemoryScope(value); - if (normalized === 'MEMORY_BUCKET_SCOPE_GLOBAL') return 'global'; - if (normalized === 'MEMORY_BUCKET_SCOPE_PER_THREAD') return 'perThread'; - return undefined; +function readQueueWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { + return readEnumValue(value, QUEUE_WHEN_BUSY_VALUES, 'agent whenBusy'); } -function mapMemoryScopeToTeam(value: unknown): TeamMemoryBucketScope | undefined { - if (typeof value === 'string') { - const trimmed = value.trim().toLowerCase(); - if (trimmed === 'global') return 'MEMORY_BUCKET_SCOPE_GLOBAL'; - if (trimmed === 'perthread' || trimmed === 'per_thread') return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - } - return normalizeMemoryScope(value); +function readQueueProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { + return readEnumValue(value, QUEUE_PROCESS_BUFFER_VALUES, 'agent processBuffer'); } -function mapWhenBusyToTeam(value: unknown): string | undefined { - if (value === 'wait') return 'AGENT_WHEN_BUSY_WAIT'; - if (value === 'injectAfterTools') return 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; - return undefined; +function readWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { + return readEnumValue(value, WORKSPACE_PLATFORM_VALUES, 'workspace platform'); } -function mapProcessBufferToTeam(value: unknown): string | undefined { - if (value === 'allTogether') return 'AGENT_PROCESS_BUFFER_ALL_TOGETHER'; - if (value === 'oneByOne') return 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; - return undefined; +function readMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { + return readEnumValue(value, MEMORY_SCOPE_VALUES, 'memory bucket scope'); } function mapAgentConfigFromTeam(raw: Record): Record { const config: Record = {}; - const model = readField(raw, ['model'], readString); + const model = readString(raw.model); if (model) config.model = model; - const systemPrompt = readField(raw, ['systemPrompt', 'system_prompt'], readString); + const systemPrompt = readOptionalString(raw.systemPrompt); if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; - const debounceMs = readField(raw, ['debounceMs', 'debounce_ms'], readNumber); - const whenBusy = parseWhenBusy(readField(raw, ['whenBusy', 'when_busy'], (value) => value)); - const processBuffer = parseProcessBuffer(readField(raw, ['processBuffer', 'process_buffer'], (value) => value)); + const debounceMs = readNumber(raw.debounceMs); + const whenBusy = readQueueWhenBusy(raw.whenBusy); + const processBuffer = readQueueProcessBuffer(raw.processBuffer); if (debounceMs !== undefined || whenBusy || processBuffer) { const queue: Record = {}; if (debounceMs !== undefined) queue.debounceMs = debounceMs; @@ -383,43 +162,27 @@ function mapAgentConfigFromTeam(raw: Record): Record = {}; if (summarizationKeepTokens !== undefined) summarization.keepTokens = summarizationKeepTokens; if (summarizationMaxTokens !== undefined) summarization.maxTokens = summarizationMaxTokens; config.summarization = summarization; } - const restrictOutput = readField(raw, ['restrictOutput', 'restrict_output'], readBoolean); + const restrictOutput = readBoolean(raw.restrictOutput); if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; - const restrictionMessage = readField(raw, ['restrictionMessage', 'restriction_message'], readString); + const restrictionMessage = readOptionalString(raw.restrictionMessage); if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; - const restrictionMaxInjections = readField( - raw, - ['restrictionMaxInjections', 'restriction_max_injections'], - readNumber, - ); + const restrictionMaxInjections = readNumber(raw.restrictionMaxInjections); if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; - const name = readField(raw, ['name'], readString); + const name = readString(raw.name); if (name) config.name = name; - const role = readField(raw, ['role'], readString); + const role = readString(raw.role); if (role) config.role = role; return config; } @@ -435,27 +198,27 @@ function mapToolConfigFromTeam(raw: Record, tool: TeamTool): Re function mapMcpConfigFromTeam(raw: Record): Record { const config: Record = {}; - const namespace = readField(raw, ['namespace'], readString); + const namespace = readString(raw.namespace); if (namespace) config.namespace = namespace; - const command = readField(raw, ['command'], readString); + const command = readString(raw.command); if (command) config.command = command; - const workdir = readField(raw, ['workdir'], readString); + const workdir = readString(raw.workdir); if (workdir) config.workdir = workdir; - const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); + const env = Array.isArray(raw.env) ? raw.env : undefined; if (env) config.env = env; - const requestTimeoutMs = readField(raw, ['requestTimeoutMs', 'request_timeout_ms'], readNumber); + const requestTimeoutMs = readNumber(raw.requestTimeoutMs); if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; - const startupTimeoutMs = readField(raw, ['startupTimeoutMs', 'startup_timeout_ms'], readNumber); + const startupTimeoutMs = readNumber(raw.startupTimeoutMs); if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; - const heartbeatIntervalMs = readField(raw, ['heartbeatIntervalMs', 'heartbeat_interval_ms'], readNumber); + const heartbeatIntervalMs = readNumber(raw.heartbeatIntervalMs); if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; - const staleTimeoutMs = readField(raw, ['staleTimeoutMs', 'stale_timeout_ms'], readNumber); + const staleTimeoutMs = readNumber(raw.staleTimeoutMs); if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; - const restart = readField(raw, ['restart'], (value) => (isRecord(value) ? value : undefined)); + const restart = isRecord(raw.restart) ? raw.restart : undefined; if (restart) { const restartConfig: Record = {}; - const maxAttempts = readField(restart, ['maxAttempts', 'max_attempts'], readNumber); - const backoffMs = readField(restart, ['backoffMs', 'backoff_ms'], readNumber); + const maxAttempts = readNumber(restart.maxAttempts); + const backoffMs = readNumber(restart.backoffMs); if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; config.restart = restartConfig; @@ -465,31 +228,32 @@ function mapMcpConfigFromTeam(raw: Record): Record): Record { const config: Record = {}; - const image = readField(raw, ['image'], readString); + const image = readString(raw.image); if (image) config.image = image; - const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); + const env = Array.isArray(raw.env) ? raw.env : undefined; if (env) config.env = env; - const initialScript = readField(raw, ['initialScript', 'initial_script'], readOptionalString); + const initialScript = readOptionalString(raw.initialScript); if (initialScript !== undefined) config.initialScript = initialScript; - const cpuLimit = readField(raw, ['cpu_limit', 'cpuLimit'], (value) => value as unknown); - if (cpuLimit !== undefined) config.cpu_limit = cpuLimit; - const memoryLimit = readField(raw, ['memory_limit', 'memoryLimit'], (value) => value as unknown); - if (memoryLimit !== undefined) config.memory_limit = memoryLimit; - const platform = readField(raw, ['platform'], (value) => value as unknown); - const platformValue = mapWorkspacePlatformToUi(platform); - if (platformValue) config.platform = platformValue; - const enableDinD = readField(raw, ['enableDinD', 'enable_dind', 'enableDind'], readBoolean); + if (typeof raw.cpuLimit === 'string' || typeof raw.cpuLimit === 'number') { + config.cpu_limit = raw.cpuLimit; + } + if (typeof raw.memoryLimit === 'string' || typeof raw.memoryLimit === 'number') { + config.memory_limit = raw.memoryLimit; + } + const platform = readWorkspacePlatform(raw.platform); + if (platform) config.platform = platform; + const enableDinD = readBoolean(raw.enableDinD); if (enableDinD !== undefined) config.enableDinD = enableDinD; - const ttlSeconds = readField(raw, ['ttlSeconds', 'ttl_seconds'], readNumber); + const ttlSeconds = readNumber(raw.ttlSeconds); if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; - const nix = readField(raw, ['nix'], (value) => (isRecord(value) ? value : undefined)); + const nix = isRecord(raw.nix) ? raw.nix : undefined; if (nix) config.nix = nix; - const volumes = readField(raw, ['volumes'], (value) => (isRecord(value) ? value : undefined)); + const volumes = isRecord(raw.volumes) ? raw.volumes : undefined; if (volumes) { const volumeConfig: Record = {}; - const enabled = readField(volumes, ['enabled'], readBoolean); + const enabled = readBoolean(volumes.enabled); if (enabled !== undefined) volumeConfig.enabled = enabled; - const mountPath = readField(volumes, ['mountPath', 'mount_path'], readOptionalString); + const mountPath = readOptionalString(volumes.mountPath); if (mountPath !== undefined) volumeConfig.mountPath = mountPath; config.volumes = volumeConfig; } @@ -498,10 +262,9 @@ function mapWorkspaceConfigFromTeam(raw: Record): Record): Record { const config: Record = {}; - const scope = readField(raw, ['scope'], (value) => value as unknown); - const scopeValue = mapMemoryScopeToUi(scope); + const scopeValue = readMemoryScope(raw.scope); if (scopeValue) config.scope = scopeValue; - const collectionPrefix = readField(raw, ['collectionPrefix', 'collection_prefix'], readOptionalString); + const collectionPrefix = readOptionalString(raw.collectionPrefix); if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; return config; } @@ -649,11 +412,10 @@ export function mapTeamEntities( for (const agent of sources.agents ?? []) { if (!agent) continue; - const id = readString(agent.id ?? agent.meta?.id); - if (!id) continue; + const id = agent.id; const template = selectTemplate(templates, 'agent', { preferredNames: ['agent'] }); const templateName = template?.name ?? 'agent'; - const config = mapAgentConfigFromTeam(isRecord(agent.config) ? agent.config : {}); + const config = mapAgentConfigFromTeam(agent.config); const title = resolveEntityTitle(readString(agent.title) ?? template?.title ?? templateName) || templateName; addSummary({ id, @@ -672,13 +434,12 @@ export function mapTeamEntities( for (const tool of sources.tools ?? []) { if (!tool) continue; - const id = readString(tool.id ?? tool.meta?.id); - if (!id) continue; - const toolType = normalizeTeamToolType(tool.type); - const templateName = (toolType && TOOL_TYPE_TO_TEMPLATE[toolType]) || 'tool'; + const id = tool.id; + const toolType = tool.type; + const templateName = TOOL_TYPE_TO_TEMPLATE[toolType] ?? 'tool'; const template = templates.find((entry) => entry.name === templateName) ?? selectTemplate(templates, 'tool'); - const config = mapToolConfigFromTeam(isRecord(tool.config) ? tool.config : {}, tool); + const config = mapToolConfigFromTeam(tool.config, tool); const titleCandidate = readString(tool.description) ?? readString(tool.name) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -700,11 +461,10 @@ export function mapTeamEntities( for (const mcpServer of sources.mcpServers ?? []) { if (!mcpServer) continue; - const id = readString(mcpServer.id ?? mcpServer.meta?.id); - if (!id) continue; + const id = mcpServer.id; const template = selectTemplate(templates, 'mcp', { preferredNames: ['mcpServer', 'mcp'] }); const templateName = template?.name ?? 'mcp'; - const config = mapMcpConfigFromTeam(isRecord(mcpServer.config) ? mcpServer.config : {}); + const config = mapMcpConfigFromTeam(mcpServer.config); const titleCandidate = readString(mcpServer.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -724,14 +484,13 @@ export function mapTeamEntities( for (const workspace of sources.workspaceConfigurations ?? []) { if (!workspace) continue; - const id = readString(workspace.id ?? workspace.meta?.id); - if (!id) continue; + const id = workspace.id; const template = selectTemplate(templates, 'workspace', { preferredNames: ['workspace'], excludeNames: EXCLUDED_WORKSPACE_TEMPLATES, }); const templateName = template?.name ?? 'workspace'; - const config = mapWorkspaceConfigFromTeam(isRecord(workspace.config) ? workspace.config : {}); + const config = mapWorkspaceConfigFromTeam(workspace.config); const titleCandidate = readString(workspace.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -751,14 +510,13 @@ export function mapTeamEntities( for (const memory of sources.memoryBuckets ?? []) { if (!memory) continue; - const id = readString(memory.id ?? memory.meta?.id); - if (!id) continue; + const id = memory.id; const template = selectTemplate(templates, 'memory', { preferredNames: ['memory'], includeNames: INCLUDED_MEMORY_TEMPLATES, }); const templateName = template?.name ?? 'memory'; - const config = mapMemoryBucketConfigFromTeam(isRecord(memory.config) ? memory.config : {}); + const config = mapMemoryBucketConfigFromTeam(memory.config); const titleCandidate = readString(memory.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -805,12 +563,9 @@ export function mapTeamAttachmentsToEdges(attachments: TeamAttachment[] | undefi const edges: GraphPersistedEdge[] = []; for (const attachment of attachments) { if (!attachment) continue; - const kind = normalizeAttachmentKind(attachment.kind); - if (!kind) continue; - const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); - const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); - if (!sourceId || !targetId) continue; - const handles = ATTACHMENT_KIND_HANDLES[kind]; + const handles = ATTACHMENT_KIND_HANDLES[attachment.kind]; + const sourceId = attachment.sourceId; + const targetId = attachment.targetId; const id = buildEdgeId(sourceId, handles.sourceHandle, targetId, handles.targetHandle); edges.push({ id, @@ -857,10 +612,6 @@ export function mapTeamEntitiesToGraphNodes( }); } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - function isEnvEntryRecord(value: Record): boolean { return typeof value.name === 'string' && Object.prototype.hasOwnProperty.call(value, 'value'); } @@ -879,13 +630,13 @@ function sanitizeEnvEntry(entry: Record): Record { - if (isPlainRecord(item) && isEnvEntryRecord(item)) { + if (isRecord(item) && isEnvEntryRecord(item)) { return sanitizeEnvEntry(item); } return sanitizeConfigValue(item); }); } - if (isPlainRecord(value)) { + if (isRecord(value)) { if (isEnvEntryRecord(value)) { return sanitizeEnvEntry(value); } @@ -899,7 +650,7 @@ function sanitizeConfigValue(value: unknown): unknown { } export function sanitizeConfigForPersistence(_templateName: string, config: Record | undefined): Record { - const base = isPlainRecord(config) ? config : {}; + const base = isRecord(config) ? config : {}; const sanitized: Record = {}; for (const [key, value] of Object.entries(base)) { if (key === 'title' || key === 'template' || key === 'kind') { @@ -946,13 +697,8 @@ export function diffTeamAttachments( const normalizedCurrent: Array<{ key: string; attachment: TeamAttachment }> = []; for (const attachment of current ?? []) { - const kind = normalizeAttachmentKind(attachment.kind); - if (!kind) continue; - const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); - const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); - if (!sourceId || !targetId) continue; - const key = `${kind}:${sourceId}:${targetId}`; - normalizedCurrent.push({ key, attachment: { ...attachment, kind } }); + const key = `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; + normalizedCurrent.push({ key, attachment }); } const currentKeys = new Set(normalizedCurrent.map((entry) => entry.key)); @@ -969,11 +715,11 @@ function mapEnvListForTeam(env: unknown): Array<{ name: string; value: string }> .filter((item) => item.name.length > 0); } -export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): TeamAgentCreateRequest { const configRecord = input.config as Record; const queue = readQueueConfig(configRecord as NodeConfig); const summarization = readSummarizationConfig(configRecord as NodeConfig); - const payload: Record = { + const payload: TeamAgentCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, @@ -982,20 +728,20 @@ export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: Grap const model = readOptionalString(configRecord.model); if (model !== undefined) config.model = model; const systemPrompt = readOptionalString(configRecord.systemPrompt); - if (systemPrompt !== undefined) config.system_prompt = systemPrompt; - if (queue.debounceMs !== undefined) config.debounce_ms = queue.debounceMs; - if (queue.whenBusy) config.when_busy = mapWhenBusyToTeam(queue.whenBusy); - if (queue.processBuffer) config.process_buffer = mapProcessBufferToTeam(queue.processBuffer); + if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; + if (queue.debounceMs !== undefined) config.debounceMs = queue.debounceMs; + if (queue.whenBusy) config.whenBusy = queue.whenBusy; + if (queue.processBuffer) config.processBuffer = queue.processBuffer; const sendFinal = readBoolean(configRecord.sendFinalResponseToThread); - if (sendFinal !== undefined) config.send_final_response_to_thread = sendFinal; - if (summarization.keepTokens !== undefined) config.summarization_keep_tokens = summarization.keepTokens; - if (summarization.maxTokens !== undefined) config.summarization_max_tokens = summarization.maxTokens; + if (sendFinal !== undefined) config.sendFinalResponseToThread = sendFinal; + if (summarization.keepTokens !== undefined) config.summarizationKeepTokens = summarization.keepTokens; + if (summarization.maxTokens !== undefined) config.summarizationMaxTokens = summarization.maxTokens; const restrictOutput = readBoolean(configRecord.restrictOutput); - if (restrictOutput !== undefined) config.restrict_output = restrictOutput; + if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; const restrictionMessage = readOptionalString(configRecord.restrictionMessage); - if (restrictionMessage !== undefined) config.restriction_message = restrictionMessage; + if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; const restrictionMaxInjections = readNumber(configRecord.restrictionMaxInjections); - if (restrictionMaxInjections !== undefined) config.restriction_max_injections = restrictionMaxInjections; + if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; const name = readOptionalString(configRecord.name); if (name !== undefined) config.name = name; const role = readOptionalString(configRecord.role); @@ -1004,7 +750,7 @@ export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: Grap return payload; } -export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): TeamToolCreateRequest { const configRecord = input.config as Record; const toolType = TEMPLATE_TO_TOOL_TYPE[input.template] ?? existing?.toolType ?? TEAM_TOOL_TYPE.manage; const toolName = readOptionalString(configRecord.name) ?? existing?.toolName ?? input.title; @@ -1016,9 +762,12 @@ export function buildToolRequest(input: GraphEntityUpsertInput, existing?: Graph }; } -export function buildMcpServerRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildMcpServerRequest( + input: GraphEntityUpsertInput, + existing?: GraphEntitySummary, +): TeamMcpServerCreateRequest { const configRecord = input.config as Record; - const payload: Record = { + const payload: TeamMcpServerCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, @@ -1033,29 +782,32 @@ export function buildMcpServerRequest(input: GraphEntityUpsertInput, existing?: const env = mapEnvListForTeam(configRecord.env); if (env.length > 0) config.env = env; const requestTimeoutMs = readNumber(configRecord.requestTimeoutMs); - if (requestTimeoutMs !== undefined) config.request_timeout_ms = requestTimeoutMs; + if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; const startupTimeoutMs = readNumber(configRecord.startupTimeoutMs); - if (startupTimeoutMs !== undefined) config.startup_timeout_ms = startupTimeoutMs; + if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; const heartbeatIntervalMs = readNumber(configRecord.heartbeatIntervalMs); - if (heartbeatIntervalMs !== undefined) config.heartbeat_interval_ms = heartbeatIntervalMs; + if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; const staleTimeoutMs = readNumber(configRecord.staleTimeoutMs); - if (staleTimeoutMs !== undefined) config.stale_timeout_ms = staleTimeoutMs; + if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; const restart = isRecord(configRecord.restart) ? configRecord.restart : {}; const maxAttempts = readNumber(restart.maxAttempts); const backoffMs = readNumber(restart.backoffMs); if (maxAttempts !== undefined || backoffMs !== undefined) { const restartConfig: Record = {}; - if (maxAttempts !== undefined) restartConfig.max_attempts = maxAttempts; - if (backoffMs !== undefined) restartConfig.backoff_ms = backoffMs; + if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; + if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; config.restart = restartConfig; } payload.config = config; return payload; } -export function buildWorkspaceRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildWorkspaceRequest( + input: GraphEntityUpsertInput, + existing?: GraphEntitySummary, +): TeamWorkspaceConfigurationCreateRequest { const configRecord = input.config as Record; - const payload: Record = { + const payload: TeamWorkspaceConfigurationCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, @@ -1066,40 +818,43 @@ export function buildWorkspaceRequest(input: GraphEntityUpsertInput, existing?: const env = mapEnvListForTeam(configRecord.env); if (env.length > 0) config.env = env; const initialScript = readOptionalString(configRecord.initialScript); - if (initialScript !== undefined) config.initial_script = initialScript; - if (configRecord.cpu_limit !== undefined) config.cpu_limit = configRecord.cpu_limit; - if (configRecord.memory_limit !== undefined) config.memory_limit = configRecord.memory_limit; - const platform = mapWorkspacePlatformToTeam(configRecord.platform); + if (initialScript !== undefined) config.initialScript = initialScript; + if (configRecord.cpu_limit !== undefined) config.cpuLimit = configRecord.cpu_limit; + if (configRecord.memory_limit !== undefined) config.memoryLimit = configRecord.memory_limit; + const platform = readWorkspacePlatform(configRecord.platform); if (platform) config.platform = platform; - const enableDinD = readBoolean(configRecord.enableDinD ?? configRecord.enable_dind ?? configRecord.enableDind); - if (enableDinD !== undefined) config.enable_dind = enableDinD; + const enableDinD = readBoolean(configRecord.enableDinD); + if (enableDinD !== undefined) config.enableDinD = enableDinD; const ttlSeconds = readNumber(configRecord.ttlSeconds); - if (ttlSeconds !== undefined) config.ttl_seconds = ttlSeconds; + if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; if (isRecord(configRecord.nix)) config.nix = configRecord.nix; if (isRecord(configRecord.volumes)) { const volumeConfig: Record = {}; const enabled = readBoolean(configRecord.volumes.enabled); if (enabled !== undefined) volumeConfig.enabled = enabled; - const mountPath = readOptionalString(configRecord.volumes.mountPath ?? configRecord.volumes.mount_path); - if (mountPath !== undefined) volumeConfig.mount_path = mountPath; + const mountPath = readOptionalString(configRecord.volumes.mountPath); + if (mountPath !== undefined) volumeConfig.mountPath = mountPath; config.volumes = volumeConfig; } payload.config = config; return payload; } -export function buildMemoryBucketRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildMemoryBucketRequest( + input: GraphEntityUpsertInput, + existing?: GraphEntitySummary, +): TeamMemoryBucketCreateRequest { const configRecord = input.config as Record; - const payload: Record = { + const payload: TeamMemoryBucketCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, }; const config: Record = {}; - const scope = mapMemoryScopeToTeam(configRecord.scope); + const scope = readMemoryScope(configRecord.scope); if (scope) config.scope = scope; - const collectionPrefix = readOptionalString(configRecord.collectionPrefix ?? configRecord.collection_prefix); - if (collectionPrefix !== undefined) config.collection_prefix = collectionPrefix; + const collectionPrefix = readOptionalString(configRecord.collectionPrefix); + if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; payload.config = config; return payload; } diff --git a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts index 868ceb9ab..0921ade8f 100644 --- a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts +++ b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts @@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ApiError } from '@/api/http'; import * as teamApi from '@/api/modules/teamApi'; import { TEAM_QUERY_KEYS, useTeamAgents, useTeamAttachments, useTeamMemoryBuckets, useTeamMcpServers, useTeamTools, useTeamWorkspaceConfigurations } from '@/api/hooks/team'; -import type { TeamAttachment } from '@/api/types/team'; import { useTemplates } from '@/lib/graph/hooks'; import { notifyError, notifySuccess } from '@/lib/notify'; import { @@ -27,21 +26,6 @@ function extractErrorMessage(error: unknown): string { return 'Request failed'; } -function readAttachmentId(attachment: TeamAttachment): string | undefined { - if (attachment.id && attachment.id.trim().length > 0) return attachment.id; - if (attachment.meta?.id && attachment.meta.id.trim().length > 0) return attachment.meta.id; - return undefined; -} - -function readAttachmentSideId(attachment: TeamAttachment, key: 'source' | 'target'): string | undefined { - const record = attachment as Record; - const direct = record[`${key}Id`]; - if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim(); - const snake = record[`${key}_id`]; - if (typeof snake === 'string' && snake.trim().length > 0) return snake.trim(); - return undefined; -} - export function useTeamEntities() { const qc = useQueryClient(); const templatesQuery = useTemplates(); @@ -87,8 +71,8 @@ export function useTeamEntities() { if (!relations) return; const current = await ensureAttachments(); const relevant = current.filter((attachment) => { - const sourceId = readAttachmentSideId(attachment, 'source'); - const targetId = readAttachmentSideId(attachment, 'target'); + const sourceId = attachment.sourceId; + const targetId = attachment.targetId; return sourceId === entityId || targetId === entityId; }); const desired = buildAttachmentInputsFromRelations(relations, entityId); @@ -96,9 +80,7 @@ export function useTeamEntities() { if (remove.length > 0) { await Promise.all( remove.map(async (attachment) => { - const id = readAttachmentId(attachment); - if (!id) return; - await teamApi.deleteAttachment(id); + await teamApi.deleteAttachment(attachment.id); }), ); } @@ -107,8 +89,6 @@ export function useTeamEntities() { create.map((attachment) => teamApi.createAttachment({ kind: attachment.kind, - source_id: attachment.sourceId, - target_id: attachment.targetId, sourceId: attachment.sourceId, targetId: attachment.targetId, }), @@ -124,10 +104,7 @@ export function useTeamEntities() { switch (input.entityKind) { case 'agent': { const created = await teamApi.createAgent(buildAgentRequest(input)); - const id = created.id ?? created.meta?.id; - if (typeof id === 'string' && id.length > 0) { - await syncAttachments(id, input.relations); - } + await syncAttachments(created.id, input.relations); return created; } case 'tool': { @@ -136,10 +113,7 @@ export function useTeamEntities() { } case 'mcp': { const created = await teamApi.createMcpServer(buildMcpServerRequest(input)); - const id = created.id ?? created.meta?.id; - if (typeof id === 'string' && id.length > 0) { - await syncAttachments(id, input.relations); - } + await syncAttachments(created.id, input.relations); return created; } case 'workspace': { @@ -168,27 +142,27 @@ export function useTeamEntities() { switch (input.entityKind) { case 'agent': { const payload = buildAgentRequest(input, existing); - const updated = await teamApi.updateAgent(input.id, { id: input.id, ...payload }); + const updated = await teamApi.updateAgent(input.id, payload); await syncAttachments(input.id, input.relations); return updated; } case 'tool': { const payload = buildToolRequest(input, existing); - return teamApi.updateTool(input.id, { id: input.id, ...payload }); + return teamApi.updateTool(input.id, payload); } case 'mcp': { const payload = buildMcpServerRequest(input, existing); - const updated = await teamApi.updateMcpServer(input.id, { id: input.id, ...payload }); + const updated = await teamApi.updateMcpServer(input.id, payload); await syncAttachments(input.id, input.relations); return updated; } case 'workspace': { const payload = buildWorkspaceRequest(input, existing); - return teamApi.updateWorkspaceConfiguration(input.id, { id: input.id, ...payload }); + return teamApi.updateWorkspaceConfiguration(input.id, payload); } case 'memory': { const payload = buildMemoryBucketRequest(input, existing); - return teamApi.updateMemoryBucket(input.id, { id: input.id, ...payload }); + return teamApi.updateMemoryBucket(input.id, payload); } default: throw new Error(`Unsupported entity kind: ${input.entityKind}`); diff --git a/packages/platform-ui/src/utils/typeGuards.ts b/packages/platform-ui/src/utils/typeGuards.ts new file mode 100644 index 000000000..fdf8baff0 --- /dev/null +++ b/packages/platform-ui/src/utils/typeGuards.ts @@ -0,0 +1,22 @@ +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function readString(value: unknown): T | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed as T; + } + } + return undefined; +} + +export function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} From ada29bb5329262979600204f273e9937dece2b1e Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 13 Mar 2026 02:10:30 +0000 Subject: [PATCH 20/43] fix(platform-ui): cap team page size --- packages/platform-ui/src/api/modules/teamApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-ui/src/api/modules/teamApi.ts b/packages/platform-ui/src/api/modules/teamApi.ts index 1b0795bb7..0ea69d72d 100644 --- a/packages/platform-ui/src/api/modules/teamApi.ts +++ b/packages/platform-ui/src/api/modules/teamApi.ts @@ -25,7 +25,7 @@ import type { import { isRecord, readNumber, readString } from '@/utils/typeGuards'; const TEAM_API_PREFIX = '/apiv2/team/v1'; -const DEFAULT_PAGE_SIZE = 200; +const DEFAULT_PAGE_SIZE = 100; export type TeamListParams = { page?: number; From 3070c98cb404da4f39db2cfda2dc8e52861ac7bf Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 21/43] feat(platform-server): add teams grpc client --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index a11e4d963..3223e425d 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -14,12 +14,8 @@ const trackedEnvKeys = [ 'AGENTS_DATABASE_URL', 'AGENTS_ENV', 'AGENTS_DEPLOYMENT', - 'DEPLOYMENT_ID', - 'TEAMS_SERVICE_ADDR', - 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', - 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From e627e3f042522d122c6c7290dd0dcbd7ea879c5f Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 13 Mar 2026 07:08:38 +0000 Subject: [PATCH 22/43] feat(platform-server): integrate Teams graph --- ...ts.persistence.ensureThreadByAlias.test.ts | 8 +- ...agents.persistence.extractKindText.test.ts | 18 +- .../agents.persistence.metrics_titles.test.ts | 103 +--- .../agents.reminders.controller.test.ts | 15 +- .../__tests__/agents.threads.filters.test.ts | 8 +- .../__tests__/agents.threads.tree.spec.ts | 8 +- .../agents/agents.persistence.service.spec.ts | 8 +- .../call_agent.parentId.integration.test.ts | 8 +- ...gent.timeline.metadata.integration.test.ts | 8 +- .../__tests__/call_agent.tool.test.ts | 8 +- .../__tests__/helpers/teamsGrpc.stub.ts | 23 + .../socket.realtime.integration.test.ts | 13 +- .../src/agents/agents.persistence.service.ts | 133 ++-- .../src/graph-domain/graph-domain.module.ts | 16 +- .../src/graph/hybridGraph.repository.ts | 127 ++++ packages/platform-server/src/graph/index.ts | 2 + .../src/graph/teamsGraph.source.ts | 581 ++++++++++++++++++ 17 files changed, 871 insertions(+), 216 deletions(-) create mode 100644 packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts create mode 100644 packages/platform-server/src/graph/hybridGraph.repository.ts create mode 100644 packages/platform-server/src/graph/teamsGraph.source.ts diff --git a/packages/platform-server/__tests__/agents.persistence.ensureThreadByAlias.test.ts b/packages/platform-server/__tests__/agents.persistence.ensureThreadByAlias.test.ts index 7ba91faea..ada7d18cd 100644 --- a/packages/platform-server/__tests__/agents.persistence.ensureThreadByAlias.test.ts +++ b/packages/platform-server/__tests__/agents.persistence.ensureThreadByAlias.test.ts @@ -4,12 +4,9 @@ import { createPrismaStub, StubPrismaService } from './helpers/prisma.stub'; import { createRunEventsStub } from './helpers/runEvents.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; import { createEventsBusStub } from './helpers/eventsBus.stub'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const metricsStub = { getThreadsMetrics: async () => ({}) } as any; -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; const createLinkingStub = () => ({ @@ -34,8 +31,7 @@ const createService = (stub: any) => { const svc = new AgentsPersistenceService( new StubPrismaService(stub) as any, metricsStub, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), eventsBusStub, diff --git a/packages/platform-server/__tests__/agents.persistence.extractKindText.test.ts b/packages/platform-server/__tests__/agents.persistence.extractKindText.test.ts index a64ae26cc..6580fd253 100644 --- a/packages/platform-server/__tests__/agents.persistence.extractKindText.test.ts +++ b/packages/platform-server/__tests__/agents.persistence.extractKindText.test.ts @@ -61,11 +61,7 @@ import type { ResponseFunctionToolCall } from 'openai/resources/responses/respon import { createRunEventsStub } from './helpers/runEvents.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; import { createEventsBusStub } from './helpers/eventsBus.stub'; - -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const createLinkingStub = () => ({ @@ -92,8 +88,7 @@ function makeService(): InstanceType { const svc = new AgentsPersistenceService( { getClient: () => ({}) } as any, metrics, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), eventsBusStub, @@ -150,8 +145,7 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text', const svc = new AgentsPersistenceService( { getClient: () => prismaMock } as any, metrics, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, linking, eventsBusStub, @@ -205,8 +199,7 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text', const svc = new AgentsPersistenceService( { getClient: () => prismaMock } as any, metrics, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), runEventsStub as any, linking, eventsBusStub, @@ -268,8 +261,7 @@ describe('AgentsPersistenceService beginRun/completeRun populates Message.text', const svc = new AgentsPersistenceService( { getClient: () => prismaMock } as any, metrics, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), runEventsStub as any, linking, eventsBusStub, diff --git a/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts b/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts index a0737505d..d5b0192de 100644 --- a/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts +++ b/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from 'vitest'; +import { create } from '@bufbuild/protobuf'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import { StubPrismaService, createPrismaStub } from './helpers/prisma.stub'; import { createRunEventsStub } from './helpers/runEvents.stub'; import { createEventsBusStub } from './helpers/eventsBus.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; +import { AgentConfigSchema, AgentSchema } from '../src/proto/gen/agynio/api/teams/v1/teams_pb'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const createLinkingStub = (overrides?: Partial) => ({ @@ -27,7 +30,7 @@ const createLinkingStub = (overrides?: Partial) => function createService( stub: any, - overrides?: { metrics?: any; templateRegistry?: any; graphRepo?: any; linking?: CallAgentLinkingService }, + overrides?: { metrics?: any; teamsClient?: ReturnType; linking?: CallAgentLinkingService }, ) { const metrics = overrides?.metrics ?? @@ -35,21 +38,12 @@ function createService( getThreadsMetrics: async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, { remindersCount: 0, containersCount: 0, activity: 'idle' as const }])), } as any); - const templateRegistry = - overrides?.templateRegistry ?? - ({ - toSchema: async () => [], - getMeta: () => undefined, - } as any); - const graphRepo = - overrides?.graphRepo ?? - ({ get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }) } as any); + const teamsClient = overrides?.teamsClient ?? createTeamsClientStub(); const eventsBusStub = createEventsBusStub(); const svc = new AgentsPersistenceService( new StubPrismaService(stub) as any, metrics, - templateRegistry, - graphRepo, + teamsClient, createRunEventsStub() as any, overrides?.linking ?? createLinkingStub(), eventsBusStub, @@ -77,41 +71,26 @@ describe('AgentsPersistenceService metrics and agent titles', () => { expect(metrics[threadB]).toEqual({ remindersCount: 2, containersCount: 1, activity: 'waiting', runsCount: 0 }); }); - it('resolves agent titles from config, template, and falls back when missing', async () => { + it('resolves agent titles from Teams config and falls back when missing', async () => { const stub = createPrismaStub(); - const templateRegistry = { - toSchema: async () => [ - { name: 'templateA', title: 'Template A', kind: 'agent', sourcePorts: [], targetPorts: [] }, - { name: 'templateB', title: 'Template B', kind: 'agent', sourcePorts: [], targetPorts: [] }, + const teamsClient = createTeamsClientStub({ + agents: [ + create(AgentSchema, { + id: 'agent-configured', + title: ' Configured Agent ', + description: '', + config: create(AgentConfigSchema, { name: ' Casey ', role: ' Lead Engineer ' }), + }), + create(AgentSchema, { + id: 'agent-profile', + title: '', + description: '', + config: create(AgentConfigSchema, { name: ' Delta ', role: ' Support ' }), + }), + create(AgentSchema, { id: 'agent-template', title: '', description: '' }), + create(AgentSchema, { id: 'agent-assigned', title: 'Assigned Only', description: '' }), ], - getMeta: (template: string) => { - if (template === 'templateA') return { title: 'Template A', kind: 'agent' }; - if (template === 'templateB') return { title: 'Template B', kind: 'agent' }; - return undefined; - }, - }; - const graphRepo = { - get: async () => ({ - name: 'main', - version: 1, - updatedAt: new Date().toISOString(), - nodes: [ - { - id: 'agent-configured', - template: 'templateA', - config: { title: ' Configured Agent ', name: ' Casey ', role: ' Lead Engineer ' }, - }, - { - id: 'agent-profile', - template: 'templateA', - config: { name: ' Delta ', role: ' Support ' }, - }, - { id: 'agent-template', template: 'templateB' }, - { id: 'agent-assigned', template: 'templateA', config: { title: 'Assigned Only' } }, - ], - edges: [], - }), - }; + }); const threadConfigured = (await stub.thread.create({ data: { alias: 'config' } })).id; const threadProfile = (await stub.thread.create({ data: { alias: 'profile' } })).id; const threadTemplate = (await stub.thread.create({ data: { alias: 'tmpl' } })).id; @@ -123,7 +102,7 @@ describe('AgentsPersistenceService metrics and agent titles', () => { await stub.thread.update({ where: { id: threadTemplate }, data: { assignedAgentNodeId: 'agent-template' } }); await stub.thread.update({ where: { id: threadAssignedOnly }, data: { assignedAgentNodeId: 'agent-assigned' } }); - const svc = createService(stub, { templateRegistry, graphRepo }); + const svc = createService(stub, { teamsClient }); const titles = await svc.getThreadsAgentTitles([ threadConfigured, @@ -134,7 +113,7 @@ describe('AgentsPersistenceService metrics and agent titles', () => { ]); expect(titles[threadConfigured]).toBe('Configured Agent'); expect(titles[threadProfile]).toBe('Delta (Support)'); - expect(titles[threadTemplate]).toBe('Template B'); + expect(titles[threadTemplate]).toBe('(unknown agent)'); expect(titles[threadFallback]).toBe('(unknown agent)'); expect(titles[threadAssignedOnly]).toBe('Assigned Only'); @@ -159,38 +138,14 @@ describe('AgentsPersistenceService metrics and agent titles', () => { ]); expect(descriptors[threadConfigured]).toEqual({ title: 'Configured Agent', role: 'Lead Engineer', name: 'Casey' }); expect(descriptors[threadProfile]).toEqual({ title: 'Delta (Support)', role: 'Support', name: 'Delta' }); - expect(descriptors[threadTemplate]).toEqual({ title: 'Template B' }); + expect(descriptors[threadTemplate]).toEqual({ title: '(unknown agent)' }); expect(descriptors[threadFallback]).toEqual({ title: '(unknown agent)' }); }); it('returns fallback descriptor when assigned agent missing', async () => { const stub = createPrismaStub(); - const templateRegistry = { - toSchema: async () => [ - { name: 'templateA', title: 'Template A', kind: 'agent', sourcePorts: [], targetPorts: [] }, - ], - getMeta: (template: string) => { - if (template === 'templateA') return { title: 'Template A', kind: 'agent' }; - return undefined; - }, - }; - const graphRepo = { - get: async () => ({ - name: 'main', - version: 1, - updatedAt: new Date().toISOString(), - nodes: [ - { - id: 'agent-linked', - template: 'templateA', - config: { title: '', name: ' Orion ', role: ' Strategist ' }, - }, - ], - edges: [], - }), - }; - const threadLinked = (await stub.thread.create({ data: { alias: 'linked' } })).id; + await stub.thread.update({ where: { id: threadLinked }, data: { assignedAgentNodeId: 'agent-linked' } }); const linking = createLinkingStub({ resolveLinkedAgentNodes: async () => { @@ -198,7 +153,7 @@ describe('AgentsPersistenceService metrics and agent titles', () => { }, }); - const svc = createService(stub, { templateRegistry, graphRepo, linking }); + const svc = createService(stub, { linking }); const descriptors = await svc.getThreadsAgentDescriptors([threadLinked]); expect(descriptors[threadLinked]).toEqual({ title: '(unknown agent)' }); diff --git a/packages/platform-server/__tests__/agents.reminders.controller.test.ts b/packages/platform-server/__tests__/agents.reminders.controller.test.ts index eae33fa6b..891a6afb8 100644 --- a/packages/platform-server/__tests__/agents.reminders.controller.test.ts +++ b/packages/platform-server/__tests__/agents.reminders.controller.test.ts @@ -9,11 +9,7 @@ import { RemindersService } from '../src/agents/reminders.service'; import { createRunEventsStub } from './helpers/runEvents.stub'; import { createEventsBusStub } from './helpers/eventsBus.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; - -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const createLinkingStub = () => ({ @@ -48,8 +44,7 @@ function createPersistenceWithTx(tx: { reminder: { findMany: any; count: any }; return new AgentsPersistenceService( prismaStub as any, { getThreadsMetrics: async () => ({}) } as any, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), createEventsBusStub(), @@ -222,8 +217,7 @@ describe('AgentsPersistenceService.listReminders', () => { const svc = new AgentsPersistenceService( prismaStub as any, { getThreadsMetrics: async () => ({}) } as any, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), eventsBusStub, @@ -259,8 +253,7 @@ describe('AgentsPersistenceService.listReminders', () => { const svc = new AgentsPersistenceService( prismaStub as any, { getThreadsMetrics: async () => ({}) } as any, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), eventsBusStub, diff --git a/packages/platform-server/__tests__/agents.threads.filters.test.ts b/packages/platform-server/__tests__/agents.threads.filters.test.ts index e0e38b1ea..21d36216a 100644 --- a/packages/platform-server/__tests__/agents.threads.filters.test.ts +++ b/packages/platform-server/__tests__/agents.threads.filters.test.ts @@ -4,12 +4,9 @@ import { StubPrismaService, createPrismaStub } from './helpers/prisma.stub'; import { createRunEventsStub } from './helpers/runEvents.stub'; import { createEventsBusStub } from './helpers/eventsBus.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const metricsStub = { getThreadsMetrics: async () => ({}) } as any; -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; const createLinkingStub = () => ({ @@ -37,8 +34,7 @@ describe('AgentsPersistenceService threads filters and updates', () => { const svc = new AgentsPersistenceService( new StubPrismaService(stub) as any, metricsStub, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), eventsBusStub, diff --git a/packages/platform-server/__tests__/agents.threads.tree.spec.ts b/packages/platform-server/__tests__/agents.threads.tree.spec.ts index 4d0ef9b89..9b5d6f388 100644 --- a/packages/platform-server/__tests__/agents.threads.tree.spec.ts +++ b/packages/platform-server/__tests__/agents.threads.tree.spec.ts @@ -4,12 +4,9 @@ import { StubPrismaService, createPrismaStub } from './helpers/prisma.stub'; import { createRunEventsStub } from './helpers/runEvents.stub'; import { createEventsBusStub } from './helpers/eventsBus.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const metricsStub = { getThreadsMetrics: async () => ({}) } as any; -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; const createLinkingStub = () => ({ @@ -35,8 +32,7 @@ function createService() { const svc = new AgentsPersistenceService( new StubPrismaService(prismaStub) as any, metricsStub, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, createLinkingStub(), createEventsBusStub(), diff --git a/packages/platform-server/__tests__/agents/agents.persistence.service.spec.ts b/packages/platform-server/__tests__/agents/agents.persistence.service.spec.ts index 6ce60265b..60904fedb 100644 --- a/packages/platform-server/__tests__/agents/agents.persistence.service.spec.ts +++ b/packages/platform-server/__tests__/agents/agents.persistence.service.spec.ts @@ -4,12 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { AgentsPersistenceService } from '../../src/agents/agents.persistence.service'; import type { PrismaService } from '../../src/core/services/prisma.service'; import type { ThreadsMetricsService } from '../../src/agents/threads.metrics.service'; -import type { TemplateRegistry } from '../../src/graph-core/templateRegistry'; -import type { GraphRepository } from '../../src/graph/graph.repository'; import type { RunEventsService } from '../../src/events/run-events.service'; import type { CallAgentLinkingService } from '../../src/agents/call-agent-linking.service'; import type { EventsBusService } from '../../src/events/events-bus.service'; import { HumanMessage } from '@agyn/llm'; +import { createTeamsClientStub } from '../helpers/teamsGrpc.stub'; describe('AgentsPersistenceService', () => { it('persists invocation messages as user role', async () => { @@ -33,8 +32,6 @@ describe('AgentsPersistenceService', () => { const prismaService = { getClient: () => prismaClient } as unknown as PrismaService; const metrics = {} as unknown as ThreadsMetricsService; - const templateRegistry = {} as unknown as TemplateRegistry; - const graphRepository = {} as unknown as GraphRepository; const runEvents = { recordInvocationMessage: vi.fn().mockResolvedValue({ id: 'event-1' }), } as unknown as RunEventsService; @@ -51,8 +48,7 @@ describe('AgentsPersistenceService', () => { const service = new AgentsPersistenceService( prismaService, metrics, - templateRegistry, - graphRepository, + createTeamsClientStub(), runEvents, callAgentLinking, eventsBus, diff --git a/packages/platform-server/__tests__/call_agent.parentId.integration.test.ts b/packages/platform-server/__tests__/call_agent.parentId.integration.test.ts index d1277324b..64c515b91 100644 --- a/packages/platform-server/__tests__/call_agent.parentId.integration.test.ts +++ b/packages/platform-server/__tests__/call_agent.parentId.integration.test.ts @@ -7,12 +7,9 @@ import { createPrismaStub, StubPrismaService } from './helpers/prisma.stub'; import { createRunEventsStub } from './helpers/runEvents.stub'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; import { createEventsBusStub } from './helpers/eventsBus.stub'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const metricsStub = { getThreadsMetrics: async () => ({}) } as any; -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; class FakeAgentWithPersistence { constructor(private persistence: AgentsPersistenceService) {} @@ -30,8 +27,7 @@ describe('call_agent integration: creates child thread with parentId', () => { const persistence = new AgentsPersistenceService( new StubPrismaService(stub) as any, metricsStub, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, { buildInitialMetadata: (params: { tool: 'call_agent' | 'call_engineer'; parentThreadId: string; childThreadId: string }) => ({ diff --git a/packages/platform-server/__tests__/call_agent.timeline.metadata.integration.test.ts b/packages/platform-server/__tests__/call_agent.timeline.metadata.integration.test.ts index 3a5e86e36..5daa16d44 100644 --- a/packages/platform-server/__tests__/call_agent.timeline.metadata.integration.test.ts +++ b/packages/platform-server/__tests__/call_agent.timeline.metadata.integration.test.ts @@ -6,10 +6,9 @@ import { RunEventsService } from '../src/events/run-events.service'; import { EventsBusService } from '../src/events/events-bus.service'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import type { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; -import type { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { GraphRepository } from '../src/graph/graph.repository'; import { HumanMessage, SystemMessage, AIMessage } from '@agyn/llm'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const databaseUrl = process.env.AGENTS_DATABASE_URL; const shouldRunDbTests = process.env.RUN_DB_TESTS === 'true' && !!databaseUrl; @@ -25,8 +24,6 @@ if (!shouldRunDbTests) { const prismaService = { getClient: () => prisma } as unknown as PrismaService; const metricsStub = { getThreadsMetrics: async () => ({}) } as ThreadsMetricsService; - const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as unknown as TemplateRegistry; - const graphRepoStub = { get: async () => ({ nodes: [], edges: [] }) } as unknown as GraphRepository; const runEvents = new RunEventsService(prismaService); const eventsBus = new EventsBusService(runEvents); @@ -34,8 +31,7 @@ if (!shouldRunDbTests) { const agents = new AgentsPersistenceService( prismaService, metricsStub, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), runEvents, callAgentLinking, eventsBus, diff --git a/packages/platform-server/__tests__/call_agent.tool.test.ts b/packages/platform-server/__tests__/call_agent.tool.test.ts index 111975735..e246b47f0 100644 --- a/packages/platform-server/__tests__/call_agent.tool.test.ts +++ b/packages/platform-server/__tests__/call_agent.tool.test.ts @@ -7,14 +7,11 @@ import { createRunEventsStub } from './helpers/runEvents.stub'; import { Signal } from '../src/signal'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; import { createEventsBusStub } from './helpers/eventsBus.stub'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const metricsStub = { getThreadsMetrics: async () => ({}) } as any; -const templateRegistryStub = { toSchema: async () => [], getMeta: () => undefined } as any; -const graphRepoStub = { - get: async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), -} as any; const createLinkingStub = () => { const spies = { @@ -43,8 +40,7 @@ const createPersistence = (linking?: CallAgentLinkingService) => { const svc = new AgentsPersistenceService( new StubPrismaService(createPrismaStub()) as any, metricsStub, - templateRegistryStub, - graphRepoStub, + createTeamsClientStub(), createRunEventsStub() as any, linking ?? createLinkingStub().instance, eventsBusStub as any, diff --git a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts new file mode 100644 index 000000000..beaa558b6 --- /dev/null +++ b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts @@ -0,0 +1,23 @@ +import type { Agent } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb'; +import type { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; + +type TeamsClientStubOptions = { + agents?: Agent[]; + listAgents?: TeamsGrpcClient['listAgents']; +}; + +export const createTeamsClientStub = (options?: TeamsClientStubOptions): TeamsGrpcClient => { + if (options?.listAgents) { + return { listAgents: options.listAgents } as unknown as TeamsGrpcClient; + } + + const agents = options?.agents ?? []; + return { + listAgents: async () => ({ + items: agents, + page: 1, + perPage: Math.max(agents.length, 1), + total: BigInt(agents.length), + }), + } as unknown as TeamsGrpcClient; +}; diff --git a/packages/platform-server/__tests__/socket.realtime.integration.test.ts b/packages/platform-server/__tests__/socket.realtime.integration.test.ts index 9b5eaeef9..34360ea80 100644 --- a/packages/platform-server/__tests__/socket.realtime.integration.test.ts +++ b/packages/platform-server/__tests__/socket.realtime.integration.test.ts @@ -11,10 +11,9 @@ import { PrismaClient, ToolExecStatus } from '@prisma/client'; import { RunEventsService } from '../src/events/run-events.service'; import { EventsBusService } from '../src/events/events-bus.service'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; -import type { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { GraphRepository } from '../src/graph/graph.repository'; import { HumanMessage, AIMessage } from '@agyn/llm'; import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; type MetricsPayload = { activity: 'working' | 'waiting' | 'idle'; remindersCount: number }; @@ -220,13 +219,10 @@ if (!shouldRunRealtimeTests) { const thread = await prisma.thread.create({ data: { alias: `thread-${randomUUID()}`, summary: 'initial' } }); await subscribeRooms(threadClient, [`thread:${thread.id}`]); - const templateRegistryStub = ({ getMeta: () => undefined }) as unknown as TemplateRegistry; - const graphRepositoryStub = ({ get: async () => ({ nodes: [] }) }) as unknown as GraphRepository; const agents = new AgentsPersistenceService( prismaService, metricsDouble.service, - templateRegistryStub, - graphRepositoryStub, + createTeamsClientStub(), runEvents, createLinkingStub(), eventsBus, @@ -275,13 +271,10 @@ if (!shouldRunRealtimeTests) { const { port } = server.address() as AddressInfo; gateway.init({ server }); - const templateRegistryStub = ({ getMeta: () => undefined }) as unknown as TemplateRegistry; - const graphRepositoryStub = ({ get: async () => ({ nodes: [] }) }) as unknown as GraphRepository; const agents = new AgentsPersistenceService( prismaService, metricsDouble.service, - templateRegistryStub, - graphRepositoryStub, + createTeamsClientStub(), runEvents, createLinkingStub(), eventsBus, diff --git a/packages/platform-server/src/agents/agents.persistence.service.ts b/packages/platform-server/src/agents/agents.persistence.service.ts index 59713404f..4d929c5af 100644 --- a/packages/platform-server/src/agents/agents.persistence.service.ts +++ b/packages/platform-server/src/agents/agents.persistence.service.ts @@ -6,13 +6,15 @@ import { ToolCallMessage, ToolCallOutputMessage, } from '@agyn/llm'; +import { create } from '@bufbuild/protobuf'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma, type MessageKind, type PrismaClient, type RunMessageType, type RunStatus, type ThreadStatus } from '@prisma/client'; import type { ResponseInputItem } from 'openai/resources/responses/responses.mjs'; import { PrismaService } from '../core/services/prisma.service'; -import { TemplateRegistry } from '../graph-core/templateRegistry'; -import { GraphRepository } from '../graph/graph.repository'; -import type { PersistedGraphNode } from '../shared/types/graph.types'; +import type { Agent } from '../proto/gen/agynio/api/teams/v1/teams_pb'; +import { ListAgentsRequestSchema } from '../proto/gen/agynio/api/teams/v1/teams_pb'; +import { TEAMS_GRPC_CLIENT } from '../teams/teamsGrpc.token'; +import type { TeamsGrpcClient } from '../teams/teamsGrpc.client'; import { toPrismaJsonValue } from '../llm/services/messages.serialization'; import { coerceRole } from '../llm/services/messages.normalization'; import { ChannelDescriptorSchema, type ChannelDescriptor } from '../messaging/types'; @@ -45,6 +47,9 @@ type ThreadTreeNode = { children?: ThreadTreeNode[]; }; +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGES = 50; + export class ThreadParentNotFoundError extends Error { constructor() { super('parent_not_found'); @@ -58,8 +63,7 @@ export class AgentsPersistenceService { constructor( @Inject(PrismaService) private prismaService: PrismaService, @Inject(ThreadsMetricsService) private readonly metrics: ThreadsMetricsService, - @Inject(TemplateRegistry) private readonly templateRegistry: TemplateRegistry, - @Inject(GraphRepository) private readonly graphs: GraphRepository, + @Inject(TEAMS_GRPC_CLIENT) private readonly teamsClient: TeamsGrpcClient, @Inject(RunEventsService) private readonly runEvents: RunEventsService, @Inject(CallAgentLinkingService) private readonly callAgentLinking: CallAgentLinkingService, @Inject(EventsBusService) private readonly eventsBus: EventsBusService, @@ -1259,73 +1263,82 @@ export class AgentsPersistenceService { ); if (!threadIds || threadIds.length === 0) return descriptors; - const graph = await this.graphs.get('main'); - if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) return descriptors; - - const computeDescriptor = (node: PersistedGraphNode): AgentDescriptor => { - const config = (node.config as Record | undefined) ?? undefined; - const rawName = typeof config?.['name'] === 'string' ? (config['name'] as string) : undefined; - const name = rawName?.trim(); - const rawRole = typeof config?.['role'] === 'string' ? (config['role'] as string) : undefined; - const role = rawRole?.trim(); - - const rawTitle = typeof config?.['title'] === 'string' ? (config['title'] as string) : undefined; - const configTitle = rawTitle?.trim(); - const templateMeta = this.templateRegistry.getMeta(node.template); - const templateTitleRaw = templateMeta?.title ?? node.template; - const templateTitle = typeof templateTitleRaw === 'string' ? templateTitleRaw.trim() : undefined; - const profileFallback = - name && name.length > 0 && role && role.length > 0 - ? `${name} (${role})` - : name && name.length > 0 - ? name - : role && role.length > 0 - ? role - : undefined; - const resolvedTitle = - configTitle && configTitle.length > 0 - ? configTitle - : profileFallback && profileFallback.length > 0 - ? profileFallback - : templateTitle && templateTitle.length > 0 - ? templateTitle - : fallback; - - const descriptor: AgentDescriptor = { title: resolvedTitle }; - if (name && name.length > 0) { - descriptor.name = name; - } - if (role && role.length > 0) { - descriptor.role = role; - } - return descriptor; - }; - - const agentNodes = graph.nodes.filter((node) => { - const meta = this.templateRegistry.getMeta(node.template); - if (meta) return meta.kind === 'agent'; - return node.template === 'agent'; - }); - if (agentNodes.length === 0) return descriptors; - - const nodeById = new Map(agentNodes.map((node) => [node.id, node])); - const threads = await this.prisma.thread.findMany({ where: { id: { in: threadIds } }, select: { id: true, assignedAgentNodeId: true }, }); + const assignedIds = new Set(); + for (const thread of threads) { + const assignedId = this.readString(thread.assignedAgentNodeId ?? undefined); + if (assignedId) assignedIds.add(assignedId); + } + if (assignedIds.size === 0) return descriptors; + + const agents = await this.listAllTeamsAgents(); + if (agents.length === 0) return descriptors; + + const agentById = new Map(); + for (const agent of agents) { + const id = this.readString(agent.id); + if (!id) continue; + agentById.set(id, agent); + } + for (const thread of threads) { - const assignedId = typeof thread.assignedAgentNodeId === 'string' ? thread.assignedAgentNodeId.trim() : ''; + const assignedId = this.readString(thread.assignedAgentNodeId ?? undefined); if (!assignedId) continue; - const node = nodeById.get(assignedId); - if (!node) continue; - descriptors[thread.id] = computeDescriptor(node); + const agent = agentById.get(assignedId); + if (!agent) continue; + descriptors[thread.id] = this.buildAgentDescriptor(agent, fallback); } return descriptors; } + private async listAllTeamsAgents(): Promise { + return this.listAllPages((page, perPage) => + this.teamsClient.listAgents(create(ListAgentsRequestSchema, { page, perPage })), + ); + } + + private async listAllPages( + fetchPage: (page: number, perPage: number) => Promise<{ items: T[]; page: number; perPage: number; total: bigint }>, + ): Promise { + const items: T[] = []; + let page = 1; + for (let i = 0; i < MAX_PAGES; i += 1) { + const response = await fetchPage(page, DEFAULT_PAGE_SIZE); + const pageItems = Array.isArray(response.items) ? response.items : []; + items.push(...pageItems); + const total = Number(response.total ?? BigInt(items.length)); + const perPage = response.perPage || DEFAULT_PAGE_SIZE; + const reachedEnd = response.page * perPage >= total; + if (reachedEnd) break; + if (pageItems.length === 0) break; + page = response.page + 1; + } + return items; + } + + private buildAgentDescriptor(agent: Agent, fallback: string): AgentDescriptor { + const name = this.readString(agent.config?.name); + const role = this.readString(agent.config?.role); + const title = this.readString(agent.title); + const profileFallback = name && role ? `${name} (${role})` : name ?? role; + const resolvedTitle = title ?? profileFallback ?? fallback; + const descriptor: AgentDescriptor = { title: resolvedTitle }; + if (name) descriptor.name = name; + if (role) descriptor.role = role; + return descriptor; + } + + private readString(value?: string | null): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + private getRunEventDelegate(tx: Prisma.TransactionClient): RunEventDelegate | undefined { const candidate = (tx as { runEvent?: RunEventDelegate }).runEvent; if (!candidate || typeof candidate.findFirst !== 'function') return undefined; diff --git a/packages/platform-server/src/graph-domain/graph-domain.module.ts b/packages/platform-server/src/graph-domain/graph-domain.module.ts index d9909b09e..04f32be7f 100644 --- a/packages/platform-server/src/graph-domain/graph-domain.module.ts +++ b/packages/platform-server/src/graph-domain/graph-domain.module.ts @@ -9,6 +9,8 @@ import { LLMModule } from '../llm/llm.module'; import { VaultModule } from '../vault/vault.module'; import { GraphRepository } from '../graph/graph.repository'; import { FsGraphRepository } from '../graph/fsGraph.repository'; +import { HybridGraphRepository } from '../graph/hybridGraph.repository'; +import { TeamsGraphSource } from '../graph/teamsGraph.source'; import { NodesModule } from '../nodes/nodes.module'; import { AgentsPersistenceService } from '../agents/agents.persistence.service'; import { ThreadsMetricsService } from '../agents/threads.metrics.service'; @@ -17,25 +19,27 @@ import { CallAgentLinkingService } from '../agents/call-agent-linking.service'; import { ThreadCleanupCoordinator } from '../agents/threadCleanup.coordinator'; import { RemindersService } from '../agents/reminders.service'; import { TemplateRegistry } from '../graph-core/templateRegistry'; +import { TeamsModule } from '../teams/teams.module'; @Global() @Module({ - imports: [CoreModule, EnvModule, EventsModule, InfraModule, VaultModule, LLMModule, NodesModule], + imports: [CoreModule, EnvModule, EventsModule, InfraModule, VaultModule, LLMModule, NodesModule, TeamsModule], providers: [ ThreadsMetricsService, RunSignalsRegistry, CallAgentLinkingService, ThreadCleanupCoordinator, RemindersService, + TeamsGraphSource, { provide: GraphRepository, - useFactory: async (config: ConfigService, moduleRef: ModuleRef) => { + useFactory: async (config: ConfigService, moduleRef: ModuleRef, teamsSource: TeamsGraphSource) => { const templateRegistry = await moduleRef.resolve(TemplateRegistry, undefined, { strict: false }); - const repo = new FsGraphRepository(config, templateRegistry); - await repo.initIfNeeded(); - return repo; + const fsRepo = new FsGraphRepository(config, templateRegistry); + await fsRepo.initIfNeeded(); + return new HybridGraphRepository(fsRepo, teamsSource); }, - inject: [ConfigService, ModuleRef], + inject: [ConfigService, ModuleRef, TeamsGraphSource], }, AgentsPersistenceService, ], diff --git a/packages/platform-server/src/graph/hybridGraph.repository.ts b/packages/platform-server/src/graph/hybridGraph.repository.ts new file mode 100644 index 000000000..c420e3a0e --- /dev/null +++ b/packages/platform-server/src/graph/hybridGraph.repository.ts @@ -0,0 +1,127 @@ +import type { + PersistedGraph, + PersistedGraphEdge, + PersistedGraphNode, + PersistedGraphUpsertRequest, + PersistedGraphUpsertResponse, +} from '../shared/types/graph.types'; +import type { GraphAuthor } from './graph.repository'; +import { GraphRepository } from './graph.repository'; +import { FsGraphRepository } from './fsGraph.repository'; +import type { TeamsGraphSnapshot } from './teamsGraph.source'; +import { TeamsGraphSource } from './teamsGraph.source'; + +export const TEAMS_MANAGED_TEMPLATES = new Set([ + 'agent', + 'manageTool', + 'memoryTool', + 'shellTool', + 'sendMessageTool', + 'sendSlackMessageTool', + 'remindMeTool', + 'githubCloneRepoTool', + 'callAgentTool', + 'workspace', + 'mcpServer', + 'memory', + 'memoryConnector', +]); + +export class HybridGraphRepository extends GraphRepository { + constructor( + private readonly fsRepo: FsGraphRepository, + private readonly teamsSource: TeamsGraphSource, + ) { + super(); + } + + async initIfNeeded(): Promise { + await this.fsRepo.initIfNeeded(); + } + + async get(name: string): Promise { + const [fsGraph, teamsGraph] = await Promise.all([this.fsRepo.get(name), this.teamsSource.load()]); + if (!fsGraph && teamsGraph.nodes.length === 0 && teamsGraph.edges.length === 0) { + return null; + } + const base: PersistedGraph = fsGraph ?? { + name, + version: 0, + updatedAt: new Date().toISOString(), + nodes: [], + edges: [], + variables: [], + }; + return this.mergeGraphs(base, teamsGraph); + } + + async upsert(req: PersistedGraphUpsertRequest, author?: GraphAuthor): Promise { + return this.fsRepo.upsert(req, author); + } + + async upsertNodeState(name: string, nodeId: string, patch: Record): Promise { + await this.fsRepo.upsertNodeState(name, nodeId, patch); + } + + private mergeGraphs(base: PersistedGraph, teamsGraph: TeamsGraphSnapshot): PersistedGraph { + const fsNodesById = new Map(base.nodes.map((node) => [node.id, node])); + const teamsNodeIds = new Set(teamsGraph.nodes.map((node) => node.id)); + const nonTeamsNodes = base.nodes.filter( + (node) => !teamsNodeIds.has(node.id) && !TEAMS_MANAGED_TEMPLATES.has(node.template), + ); + const mergedTeamsNodes = teamsGraph.nodes.map((node) => this.mergeNode(node, fsNodesById.get(node.id))); + const nodes = [...nonTeamsNodes, ...mergedTeamsNodes]; + const nodeIds = new Set(nodes.map((node) => node.id)); + const edges = this.mergeEdges(base.edges, teamsGraph.edges, nodeIds, teamsNodeIds); + return { + ...base, + nodes, + edges, + }; + } + + private mergeNode(teamsNode: PersistedGraphNode, fsNode?: PersistedGraphNode): PersistedGraphNode { + const merged: PersistedGraphNode = { ...teamsNode }; + if (merged.state === undefined && fsNode?.state !== undefined) { + merged.state = fsNode.state; + } + if (merged.position === undefined && fsNode?.position !== undefined) { + merged.position = fsNode.position; + } + return merged; + } + + private mergeEdges( + fsEdges: PersistedGraphEdge[], + teamsEdges: PersistedGraphEdge[], + nodeIds: Set, + teamsNodeIds: Set, + ): PersistedGraphEdge[] { + const edges: PersistedGraphEdge[] = []; + const seen = new Set(); + + const addEdge = (edge: PersistedGraphEdge): void => { + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return; + if (!edge.sourceHandle || !edge.targetHandle) return; + const key = this.edgeKey(edge); + if (seen.has(key)) return; + seen.add(key); + edges.push({ ...edge, id: key }); + }; + + for (const edge of fsEdges) { + if (teamsNodeIds.has(edge.source) && teamsNodeIds.has(edge.target)) continue; + addEdge(edge); + } + + for (const edge of teamsEdges) { + addEdge(edge); + } + + return edges; + } + + private edgeKey(edge: PersistedGraphEdge): string { + return `${edge.source}-${edge.sourceHandle}__${edge.target}-${edge.targetHandle}`; + } +} diff --git a/packages/platform-server/src/graph/index.ts b/packages/platform-server/src/graph/index.ts index 107b49169..90c4d92b9 100644 --- a/packages/platform-server/src/graph/index.ts +++ b/packages/platform-server/src/graph/index.ts @@ -7,4 +7,6 @@ export * from './ports.types'; export * from './ports.registry'; export * from './graph.repository'; export * from './fsGraph.repository'; +export * from './hybridGraph.repository'; +export * from './teamsGraph.source'; export * from './graph-api.module'; diff --git a/packages/platform-server/src/graph/teamsGraph.source.ts b/packages/platform-server/src/graph/teamsGraph.source.ts new file mode 100644 index 000000000..a23650b29 --- /dev/null +++ b/packages/platform-server/src/graph/teamsGraph.source.ts @@ -0,0 +1,581 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { create, toJson } from '@bufbuild/protobuf'; +import { ValueSchema } from '@bufbuild/protobuf/wkt'; + +import type { PersistedGraphEdge, PersistedGraphNode } from '../shared/types/graph.types'; +import { TEAMS_GRPC_CLIENT } from '../teams/teamsGrpc.token'; +import type { TeamsGrpcClient } from '../teams/teamsGrpc.client'; +import { + Agent, + AgentProcessBuffer, + AgentWhenBusy, + Attachment, + AttachmentKind, + EntityType, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + McpServer, + MemoryBucket, + MemoryBucketScope, + Tool, + ToolType, + WorkspaceConfig, + WorkspaceConfiguration, + WorkspacePlatform, +} from '../proto/gen/agynio/api/teams/v1/teams_pb'; + +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGES = 50; + +const TOOL_TYPES: ToolType[] = [ + ToolType.MANAGE, + ToolType.MEMORY, + ToolType.SHELL_COMMAND, + ToolType.SEND_MESSAGE, + ToolType.SEND_SLACK_MESSAGE, + ToolType.REMIND_ME, + ToolType.GITHUB_CLONE_REPO, + ToolType.CALL_AGENT, +]; + +const TOOL_TYPE_TO_TEMPLATE: Record = { + [ToolType.UNSPECIFIED]: undefined, + [ToolType.MANAGE]: 'manageTool', + [ToolType.MEMORY]: 'memoryTool', + [ToolType.SHELL_COMMAND]: 'shellTool', + [ToolType.SEND_MESSAGE]: 'sendMessageTool', + [ToolType.SEND_SLACK_MESSAGE]: 'sendSlackMessageTool', + [ToolType.REMIND_ME]: 'remindMeTool', + [ToolType.GITHUB_CLONE_REPO]: 'githubCloneRepoTool', + [ToolType.CALL_AGENT]: 'callAgentTool', +}; + +const TOOL_TYPES_WITH_NAME = new Set([ToolType.MANAGE, ToolType.MEMORY, ToolType.CALL_AGENT]); +const TOOL_TYPES_WITH_DESCRIPTION = TOOL_TYPES_WITH_NAME; + +const ATTACHMENT_FILTERS: Array<{ kind: AttachmentKind; sourceType: EntityType; targetType: EntityType }> = [ + { kind: AttachmentKind.AGENT_TOOL, sourceType: EntityType.AGENT, targetType: EntityType.TOOL }, + { kind: AttachmentKind.AGENT_MEMORY_BUCKET, sourceType: EntityType.AGENT, targetType: EntityType.MEMORY_BUCKET }, + { + kind: AttachmentKind.AGENT_WORKSPACE_CONFIGURATION, + sourceType: EntityType.AGENT, + targetType: EntityType.WORKSPACE_CONFIGURATION, + }, + { kind: AttachmentKind.AGENT_MCP_SERVER, sourceType: EntityType.AGENT, targetType: EntityType.MCP_SERVER }, + { + kind: AttachmentKind.MCP_SERVER_WORKSPACE_CONFIGURATION, + sourceType: EntityType.MCP_SERVER, + targetType: EntityType.WORKSPACE_CONFIGURATION, + }, +]; + +export type TeamsGraphSnapshot = { nodes: PersistedGraphNode[]; edges: PersistedGraphEdge[] }; + +@Injectable() +export class TeamsGraphSource { + constructor(@Inject(TEAMS_GRPC_CLIENT) private readonly teams: TeamsGrpcClient) {} + + async load(): Promise { + const [agents, tools, mcps, workspaces, memoryBuckets, attachments] = await Promise.all([ + this.listAllAgents(), + this.listAllTools(), + this.listAllMcpServers(), + this.listAllWorkspaces(), + this.listAllMemoryBuckets(), + this.listAllAttachments(), + ]); + + const nodes: PersistedGraphNode[] = []; + const edges: PersistedGraphEdge[] = []; + const nodeIds = new Set(); + const edgeKeys = new Set(); + const toolTemplateById = new Map(); + const agentTools = new Map>(); + const agentMcps = new Map>(); + const agentWorkspaces = new Map>(); + + const addNode = (node: PersistedGraphNode): void => { + if (!node.id || nodeIds.has(node.id)) return; + nodes.push(node); + nodeIds.add(node.id); + }; + + const addEdge = (source: string, sourceHandle: string, target: string, targetHandle: string): void => { + if (!nodeIds.has(source) || !nodeIds.has(target)) return; + if (!sourceHandle || !targetHandle) return; + const key = this.edgeKey({ source, sourceHandle, target, targetHandle }); + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push({ id: key, source, sourceHandle, target, targetHandle }); + }; + + for (const agent of agents) { + const id = this.normalizeId(agent.id); + if (!id) continue; + addNode({ id, template: 'agent', config: this.mapAgentConfig(agent) }); + } + + for (const tool of tools) { + const id = this.normalizeId(tool.id); + if (!id) continue; + const template = TOOL_TYPE_TO_TEMPLATE[tool.type]; + if (!template) continue; + toolTemplateById.set(id, template); + addNode({ id, template, config: this.mapToolConfig(tool) }); + } + + for (const mcp of mcps) { + const id = this.normalizeId(mcp.id); + if (!id) continue; + addNode({ id, template: 'mcpServer', config: this.mapMcpConfig(mcp) }); + } + + for (const workspace of workspaces) { + const id = this.normalizeId(workspace.id); + if (!id) continue; + addNode({ id, template: 'workspace', config: this.mapWorkspaceConfig(workspace) }); + } + + for (const memory of memoryBuckets) { + const id = this.normalizeId(memory.id); + if (!id) continue; + addNode({ id, template: 'memory', config: this.mapMemoryBucketConfig(memory) }); + } + + for (const attachment of attachments) { + switch (attachment.kind) { + case AttachmentKind.AGENT_TOOL: { + if (!this.matchesAttachmentTypes(attachment, EntityType.AGENT, EntityType.TOOL)) break; + const agentId = this.normalizeId(attachment.sourceId); + const toolId = this.normalizeId(attachment.targetId); + if (!agentId || !toolId) break; + addEdge(agentId, 'tools', toolId, '$self'); + this.addToSet(agentTools, agentId, toolId); + break; + } + case AttachmentKind.AGENT_MCP_SERVER: { + if (!this.matchesAttachmentTypes(attachment, EntityType.AGENT, EntityType.MCP_SERVER)) break; + const agentId = this.normalizeId(attachment.sourceId); + const mcpId = this.normalizeId(attachment.targetId); + if (!agentId || !mcpId) break; + addEdge(agentId, 'mcp', mcpId, '$self'); + this.addToSet(agentMcps, agentId, mcpId); + break; + } + case AttachmentKind.AGENT_MEMORY_BUCKET: { + if (!this.matchesAttachmentTypes(attachment, EntityType.AGENT, EntityType.MEMORY_BUCKET)) break; + const agentId = this.normalizeId(attachment.sourceId); + const memoryId = this.normalizeId(attachment.targetId); + if (!agentId || !memoryId) break; + if (!nodeIds.has(agentId) || !nodeIds.has(memoryId)) break; + const connectorId = this.memoryConnectorId(agentId, memoryId); + if (!nodeIds.has(connectorId)) { + addNode({ id: connectorId, template: 'memoryConnector' }); + } + addEdge(memoryId, '$self', connectorId, '$memory'); + addEdge(connectorId, '$self', agentId, 'memory'); + break; + } + case AttachmentKind.AGENT_WORKSPACE_CONFIGURATION: { + if (!this.matchesAttachmentTypes(attachment, EntityType.AGENT, EntityType.WORKSPACE_CONFIGURATION)) break; + const agentId = this.normalizeId(attachment.sourceId); + const workspaceId = this.normalizeId(attachment.targetId); + if (!agentId || !workspaceId) break; + this.addToSet(agentWorkspaces, agentId, workspaceId); + break; + } + case AttachmentKind.MCP_SERVER_WORKSPACE_CONFIGURATION: { + if (!this.matchesAttachmentTypes(attachment, EntityType.MCP_SERVER, EntityType.WORKSPACE_CONFIGURATION)) break; + const mcpId = this.normalizeId(attachment.sourceId); + const workspaceId = this.normalizeId(attachment.targetId); + if (!mcpId || !workspaceId) break; + addEdge(workspaceId, '$self', mcpId, 'workspace'); + break; + } + default: + break; + } + } + + for (const [agentId, workspaceIds] of agentWorkspaces) { + const toolIds = agentTools.get(agentId); + const mcpIds = agentMcps.get(agentId); + this.addWorkspaceEdges(workspaceIds, toolIds, mcpIds, toolTemplateById, addEdge); + } + + return { nodes, edges }; + } + + private async listAllAgents(): Promise { + return this.listAllPages((page, perPage) => + this.teams.listAgents(create(ListAgentsRequestSchema, { page, perPage })), + ); + } + + private async listAllTools(): Promise { + const collected = new Map(); + for (const type of TOOL_TYPES) { + const items = await this.listAllPages((page, perPage) => + this.teams.listTools(create(ListToolsRequestSchema, { type, page, perPage })), + ); + for (const tool of items) { + const id = this.normalizeId(tool.id); + if (!id || collected.has(id)) continue; + collected.set(id, tool); + } + } + return Array.from(collected.values()); + } + + private async listAllMcpServers(): Promise { + return this.listAllPages((page, perPage) => + this.teams.listMcpServers(create(ListMcpServersRequestSchema, { page, perPage })), + ); + } + + private async listAllWorkspaces(): Promise { + return this.listAllPages((page, perPage) => + this.teams.listWorkspaceConfigurations(create(ListWorkspaceConfigurationsRequestSchema, { page, perPage })), + ); + } + + private async listAllMemoryBuckets(): Promise { + return this.listAllPages((page, perPage) => + this.teams.listMemoryBuckets(create(ListMemoryBucketsRequestSchema, { page, perPage })), + ); + } + + private async listAllAttachments(): Promise { + const collected = new Map(); + for (const filter of ATTACHMENT_FILTERS) { + const items = await this.listAllPages((page, perPage) => + this.teams.listAttachments( + create(ListAttachmentsRequestSchema, { + kind: filter.kind, + sourceType: filter.sourceType, + targetType: filter.targetType, + page, + perPage, + }), + ), + ); + for (const attachment of items) { + const key = this.normalizeId(attachment.id) ?? `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; + if (collected.has(key)) continue; + collected.set(key, attachment); + } + } + return Array.from(collected.values()); + } + + private async listAllPages(fetchPage: (page: number, perPage: number) => Promise<{ items: T[]; page: number; perPage: number; total: bigint }>): Promise { + const items: T[] = []; + let page = 1; + for (let i = 0; i < MAX_PAGES; i += 1) { + const response = await fetchPage(page, DEFAULT_PAGE_SIZE); + const pageItems = Array.isArray(response.items) ? response.items : []; + items.push(...pageItems); + const total = Number(response.total ?? BigInt(items.length)); + const perPage = response.perPage || DEFAULT_PAGE_SIZE; + const reachedEnd = response.page * perPage >= total; + if (reachedEnd) break; + if (pageItems.length === 0) break; + page = response.page + 1; + } + return items; + } + + private mapAgentConfig(agent: Agent): Record | undefined { + const config: Record = {}; + const title = this.readString(agent.title); + if (title) config.title = title; + + const raw = agent.config; + if (raw) { + const model = this.readString(raw.model); + if (model) config.model = model; + const systemPrompt = this.readOptionalString(raw.systemPrompt); + if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; + const debounceMs = this.readNumber(raw.debounceMs); + if (debounceMs !== undefined) config.debounceMs = debounceMs; + const whenBusy = this.mapWhenBusy(raw.whenBusy); + if (whenBusy) config.whenBusy = whenBusy; + const processBuffer = this.mapProcessBuffer(raw.processBuffer); + if (processBuffer) config.processBuffer = processBuffer; + if (typeof raw.sendFinalResponseToThread === 'boolean') { + config.sendFinalResponseToThread = raw.sendFinalResponseToThread; + } + const summarizationKeepTokens = this.readNumber(raw.summarizationKeepTokens); + if (summarizationKeepTokens !== undefined) config.summarizationKeepTokens = summarizationKeepTokens; + const summarizationMaxTokens = this.readNumber(raw.summarizationMaxTokens); + if (summarizationMaxTokens !== undefined) config.summarizationMaxTokens = summarizationMaxTokens; + if (typeof raw.restrictOutput === 'boolean') { + config.restrictOutput = raw.restrictOutput; + } + const restrictionMessage = this.readOptionalString(raw.restrictionMessage); + if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; + const restrictionMaxInjections = this.readNumber(raw.restrictionMaxInjections); + if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; + const name = this.readString(raw.name); + if (name) config.name = name; + const role = this.readString(raw.role); + if (role) config.role = role; + } + + return Object.keys(config).length > 0 ? config : undefined; + } + + private mapToolConfig(tool: Tool): Record | undefined { + const config: Record = tool.config ? { ...tool.config } : {}; + const title = this.readString(tool.name); + if (title) config.title = title; + if (TOOL_TYPES_WITH_NAME.has(tool.type) && title) { + config.name = title; + } + const description = this.readOptionalString(tool.description); + if (TOOL_TYPES_WITH_DESCRIPTION.has(tool.type) && description !== undefined) { + config.description = description; + } + return Object.keys(config).length > 0 ? config : undefined; + } + + private mapMcpConfig(mcp: McpServer): Record | undefined { + const config: Record = {}; + const title = this.readString(mcp.title); + if (title) config.title = title; + const raw = mcp.config; + if (raw) { + const namespace = this.readString(raw.namespace); + if (namespace) config.namespace = namespace; + const command = this.readString(raw.command); + if (command) config.command = command; + const workdir = this.readString(raw.workdir); + if (workdir) config.workdir = workdir; + const env = this.mapEnvItems(raw.env); + if (env) config.env = env; + const requestTimeoutMs = this.readPositiveNumber(raw.requestTimeoutMs); + if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; + const startupTimeoutMs = this.readPositiveNumber(raw.startupTimeoutMs); + if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; + const heartbeatIntervalMs = this.readPositiveNumber(raw.heartbeatIntervalMs); + if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; + const staleTimeoutMs = this.readNonNegativeNumber(raw.staleTimeoutMs); + if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; + if (raw.restart) { + const restart: Record = {}; + const maxAttempts = this.readPositiveNumber(raw.restart.maxAttempts); + if (maxAttempts !== undefined) restart.maxAttempts = maxAttempts; + const backoffMs = this.readPositiveNumber(raw.restart.backoffMs); + if (backoffMs !== undefined) restart.backoffMs = backoffMs; + if (Object.keys(restart).length > 0) config.restart = restart; + } + } + return Object.keys(config).length > 0 ? config : undefined; + } + + private mapWorkspaceConfig(workspace: WorkspaceConfiguration): Record | undefined { + const config: Record = {}; + const title = this.readString(workspace.title); + if (title) config.title = title; + const raw = workspace.config; + if (raw) { + const image = this.readString(raw.image); + if (image) config.image = image; + const env = this.mapEnvItems(raw.env); + if (env) config.env = env; + const initialScript = this.readOptionalString(raw.initialScript); + if (initialScript !== undefined) config.initialScript = initialScript; + const cpuLimit = this.readValue(raw.cpuLimit); + if (cpuLimit !== undefined) config.cpu_limit = cpuLimit; + const memoryLimit = this.readValue(raw.memoryLimit); + if (memoryLimit !== undefined) config.memory_limit = memoryLimit; + const platform = this.mapWorkspacePlatform(raw.platform); + if (platform) config.platform = platform; + if (typeof raw.enableDind === 'boolean') config.enableDinD = raw.enableDind; + const ttlSeconds = this.readNumber(raw.ttlSeconds); + if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; + if (raw.nix) config.nix = raw.nix; + if (raw.volumes) { + const volumes: Record = {}; + if (typeof raw.volumes.enabled === 'boolean') volumes.enabled = raw.volumes.enabled; + const mountPath = this.readOptionalString(raw.volumes.mountPath); + if (mountPath !== undefined) volumes.mountPath = mountPath; + if (Object.keys(volumes).length > 0) config.volumes = volumes; + } + } + return Object.keys(config).length > 0 ? config : undefined; + } + + private mapMemoryBucketConfig(bucket: MemoryBucket): Record | undefined { + const config: Record = {}; + const title = this.readString(bucket.title); + if (title) config.title = title; + const raw = bucket.config; + if (raw) { + const scope = this.mapMemoryScope(raw.scope); + if (scope) config.scope = scope; + const collectionPrefix = this.readOptionalString(raw.collectionPrefix); + if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; + } + return Object.keys(config).length > 0 ? config : undefined; + } + + private mapWhenBusy(value: AgentWhenBusy): 'wait' | 'injectAfterTools' | undefined { + switch (value) { + case AgentWhenBusy.WAIT: + return 'wait'; + case AgentWhenBusy.INJECT_AFTER_TOOLS: + return 'injectAfterTools'; + default: + return undefined; + } + } + + private mapProcessBuffer(value: AgentProcessBuffer): 'allTogether' | 'oneByOne' | undefined { + switch (value) { + case AgentProcessBuffer.ALL_TOGETHER: + return 'allTogether'; + case AgentProcessBuffer.ONE_BY_ONE: + return 'oneByOne'; + default: + return undefined; + } + } + + private mapWorkspacePlatform(value: WorkspacePlatform): 'linux/amd64' | 'linux/arm64' | 'auto' | undefined { + switch (value) { + case WorkspacePlatform.LINUX_AMD64: + return 'linux/amd64'; + case WorkspacePlatform.LINUX_ARM64: + return 'linux/arm64'; + case WorkspacePlatform.AUTO: + return 'auto'; + default: + return undefined; + } + } + + private mapMemoryScope(value: MemoryBucketScope): 'global' | 'perThread' | undefined { + switch (value) { + case MemoryBucketScope.GLOBAL: + return 'global'; + case MemoryBucketScope.PER_THREAD: + return 'perThread'; + default: + return undefined; + } + } + + private mapEnvItems(items?: Array<{ name: string; value: string }>): Array<{ name: string; value: string }> | undefined { + if (!items || items.length === 0) return undefined; + const mapped: Array<{ name: string; value: string }> = []; + for (const item of items) { + const name = this.readString(item.name); + if (!name) continue; + mapped.push({ name, value: item.value }); + } + return mapped.length > 0 ? mapped : undefined; + } + + private readValue(value?: WorkspaceConfig['cpuLimit']): string | number | undefined { + if (!value) return undefined; + const json = toJson(ValueSchema, value); + if (typeof json === 'string' || typeof json === 'number') return json; + return undefined; + } + + private readNumber(value?: number): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; + return value; + } + + private readPositiveNumber(value?: number): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined; + return value; + } + + private readNonNegativeNumber(value?: number): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined; + return value; + } + + private readString(value?: string): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private readOptionalString(value?: string): string | undefined { + if (typeof value !== 'string') return undefined; + return value; + } + + private normalizeId(value?: string): string | undefined { + return this.readString(value); + } + + private addToSet(map: Map>, key: string, value: string): void { + const existing = map.get(key); + if (existing) { + existing.add(value); + return; + } + map.set(key, new Set([value])); + } + + private addWorkspaceEdges( + workspaceIds: Set, + toolIds: Set | undefined, + mcpIds: Set | undefined, + toolTemplateById: Map, + addEdge: (source: string, sourceHandle: string, target: string, targetHandle: string) => void, + ): void { + if (!toolIds && !mcpIds) return; + for (const workspaceId of workspaceIds) { + if (toolIds) { + this.addWorkspaceToolEdges(workspaceId, toolIds, toolTemplateById, addEdge); + } + if (mcpIds) { + this.addWorkspaceMcpEdges(workspaceId, mcpIds, addEdge); + } + } + } + + private addWorkspaceToolEdges( + workspaceId: string, + toolIds: Set, + toolTemplateById: Map, + addEdge: (source: string, sourceHandle: string, target: string, targetHandle: string) => void, + ): void { + for (const toolId of toolIds) { + if (toolTemplateById.get(toolId) !== 'shellTool') continue; + addEdge(workspaceId, '$self', toolId, 'workspace'); + } + } + + private addWorkspaceMcpEdges( + workspaceId: string, + mcpIds: Set, + addEdge: (source: string, sourceHandle: string, target: string, targetHandle: string) => void, + ): void { + for (const mcpId of mcpIds) { + addEdge(workspaceId, '$self', mcpId, 'workspace'); + } + } + + private edgeKey(edge: PersistedGraphEdge): string { + return `${edge.source}-${edge.sourceHandle}__${edge.target}-${edge.targetHandle}`; + } + + private memoryConnectorId(agentId: string, memoryId: string): string { + return `memoryConnector:${agentId}:${memoryId}`; + } + + private matchesAttachmentTypes(attachment: Attachment, source: EntityType, target: EntityType): boolean { + return attachment.sourceType === source && attachment.targetType === target; + } +} From 993c3c1b3ea85c44cc142b36d054625b4cce3f73 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 13 Mar 2026 07:55:08 +0000 Subject: [PATCH 23/43] test(platform-server): add Teams graph tests --- .../__tests__/helpers/teamsGrpc.stub.ts | 75 ++++++- .../__tests__/hybridGraph.repository.test.ts | 57 ++++++ .../__tests__/teamsGraph.source.test.ts | 188 ++++++++++++++++++ .../src/agents/agents.persistence.service.ts | 43 +--- .../platform-server/src/graph/graph.utils.ts | 4 + .../src/graph/hybridGraph.repository.ts | 6 +- .../src/graph/teamsGraph.source.ts | 74 +++---- .../src/teams/teamsGrpc.pagination.ts | 37 ++++ 8 files changed, 383 insertions(+), 101 deletions(-) create mode 100644 packages/platform-server/__tests__/hybridGraph.repository.test.ts create mode 100644 packages/platform-server/__tests__/teamsGraph.source.test.ts create mode 100644 packages/platform-server/src/graph/graph.utils.ts create mode 100644 packages/platform-server/src/teams/teamsGrpc.pagination.ts diff --git a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts index beaa558b6..0d01cb99f 100644 --- a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts +++ b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts @@ -1,23 +1,76 @@ -import type { Agent } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb'; +import type { + Agent, + Attachment, + McpServer, + MemoryBucket, + Tool, + WorkspaceConfiguration, +} from '../../src/proto/gen/agynio/api/teams/v1/teams_pb'; import type { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; type TeamsClientStubOptions = { agents?: Agent[]; + tools?: Tool[]; + mcps?: McpServer[]; + workspaces?: WorkspaceConfiguration[]; + memoryBuckets?: MemoryBucket[]; + attachments?: Attachment[]; listAgents?: TeamsGrpcClient['listAgents']; + listTools?: TeamsGrpcClient['listTools']; + listMcpServers?: TeamsGrpcClient['listMcpServers']; + listWorkspaceConfigurations?: TeamsGrpcClient['listWorkspaceConfigurations']; + listMemoryBuckets?: TeamsGrpcClient['listMemoryBuckets']; + listAttachments?: TeamsGrpcClient['listAttachments']; }; export const createTeamsClientStub = (options?: TeamsClientStubOptions): TeamsGrpcClient => { - if (options?.listAgents) { - return { listAgents: options.listAgents } as unknown as TeamsGrpcClient; - } - const agents = options?.agents ?? []; + const tools = options?.tools ?? []; + const mcps = options?.mcps ?? []; + const workspaces = options?.workspaces ?? []; + const memoryBuckets = options?.memoryBuckets ?? []; + const attachments = options?.attachments ?? []; + + const paginate = (items: T[], page: number, perPage: number) => { + const start = Math.max(0, (page - 1) * perPage); + return { + items: items.slice(start, start + perPage), + page, + perPage, + total: BigInt(items.length), + }; + }; + + const listAgents = options?.listAgents ?? (async (request: { page: number; perPage: number }) => paginate(agents, request.page, request.perPage)); + const listTools = options?.listTools ?? + (async (request: { page: number; perPage: number; type?: Tool['type'] }) => + paginate( + request.type === undefined ? tools : tools.filter((tool) => tool.type === request.type), + request.page, + request.perPage, + )); + const listMcpServers = options?.listMcpServers ?? (async (request: { page: number; perPage: number }) => paginate(mcps, request.page, request.perPage)); + const listWorkspaceConfigurations = options?.listWorkspaceConfigurations ?? + (async (request: { page: number; perPage: number }) => paginate(workspaces, request.page, request.perPage)); + const listMemoryBuckets = options?.listMemoryBuckets ?? + (async (request: { page: number; perPage: number }) => paginate(memoryBuckets, request.page, request.perPage)); + const listAttachments = options?.listAttachments ?? + (async (request: { page: number; perPage: number; kind?: Attachment['kind']; sourceType?: Attachment['sourceType']; targetType?: Attachment['targetType'] }) => + paginate( + attachments.filter((attachment) => + attachment.kind === request.kind + && attachment.sourceType === request.sourceType + && attachment.targetType === request.targetType), + request.page, + request.perPage, + )); + return { - listAgents: async () => ({ - items: agents, - page: 1, - perPage: Math.max(agents.length, 1), - total: BigInt(agents.length), - }), + listAgents, + listTools, + listMcpServers, + listWorkspaceConfigurations, + listMemoryBuckets, + listAttachments, } as unknown as TeamsGrpcClient; }; diff --git a/packages/platform-server/__tests__/hybridGraph.repository.test.ts b/packages/platform-server/__tests__/hybridGraph.repository.test.ts new file mode 100644 index 000000000..0091c7ca9 --- /dev/null +++ b/packages/platform-server/__tests__/hybridGraph.repository.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import type { PersistedGraph } from '../src/shared/types/graph.types'; +import type { TeamsGraphSnapshot } from '../src/graph/teamsGraph.source'; +import { HybridGraphRepository } from '../src/graph/hybridGraph.repository'; +import { edgeKey } from '../src/graph/graph.utils'; + +describe('HybridGraphRepository mergeGraphs', () => { + it('preserves FS-only nodes and merges Teams-managed nodes', () => { + const repo = new HybridGraphRepository({} as any, {} as any); + const base: PersistedGraph = { + name: 'main', + version: 1, + updatedAt: '2024-01-01T00:00:00.000Z', + nodes: [ + { id: 'trigger-1', template: 'trigger', config: { label: 'start' } }, + { id: 'agent-1', template: 'agent', config: { title: 'FS Agent' }, state: { status: 'idle' }, position: { x: 5, y: 7 } }, + { id: 'workspace-orphan', template: 'workspace', config: { title: 'FS Workspace' } }, + ], + edges: [ + { source: 'trigger-1', sourceHandle: 'out', target: 'agent-1', targetHandle: 'in' }, + { source: 'workspace-orphan', sourceHandle: 'out', target: 'agent-1', targetHandle: 'in' }, + ], + variables: [], + }; + const teamsGraph: TeamsGraphSnapshot = { + nodes: [ + { id: 'agent-1', template: 'agent', config: { title: 'Teams Agent' } }, + { id: 'workspace-1', template: 'workspace', config: { title: 'Teams Workspace' } }, + ], + edges: [ + { source: 'workspace-1', sourceHandle: '$self', target: 'agent-1', targetHandle: 'tools' }, + ], + }; + + const merged = (repo as any).mergeGraphs(base, teamsGraph) as PersistedGraph; + const nodesById = new Map(merged.nodes.map((node) => [node.id, node])); + + expect(nodesById.has('workspace-orphan')).toBe(false); + expect(nodesById.get('trigger-1')).toMatchObject({ template: 'trigger', config: { label: 'start' } }); + expect(nodesById.get('workspace-1')).toMatchObject({ template: 'workspace', config: { title: 'Teams Workspace' } }); + expect(nodesById.get('agent-1')).toMatchObject({ + template: 'agent', + config: { title: 'Teams Agent' }, + state: { status: 'idle' }, + position: { x: 5, y: 7 }, + }); + + const edgeKeys = merged.edges.map(edgeKey); + const expectedEdges = [ + edgeKey({ source: 'trigger-1', sourceHandle: 'out', target: 'agent-1', targetHandle: 'in' }), + edgeKey({ source: 'workspace-1', sourceHandle: '$self', target: 'agent-1', targetHandle: 'tools' }), + ]; + + expect(edgeKeys).toHaveLength(expectedEdges.length); + expect(edgeKeys).toEqual(expect.arrayContaining(expectedEdges)); + }); +}); diff --git a/packages/platform-server/__tests__/teamsGraph.source.test.ts b/packages/platform-server/__tests__/teamsGraph.source.test.ts new file mode 100644 index 000000000..930938fcf --- /dev/null +++ b/packages/platform-server/__tests__/teamsGraph.source.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { create } from '@bufbuild/protobuf'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; +import { edgeKey } from '../src/graph/graph.utils'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; +import { + AgentConfigSchema, + AgentSchema, + AttachmentKind, + AttachmentSchema, + EntityType, + McpServerSchema, + MemoryBucketConfigSchema, + MemoryBucketScope, + MemoryBucketSchema, + ToolSchema, + ToolType, + WorkspaceConfigSchema, + WorkspaceConfigurationSchema, + WorkspacePlatform, +} from '../src/proto/gen/agynio/api/teams/v1/teams_pb'; + +describe('TeamsGraphSource', () => { + it('maps Teams entities and attachments into graph nodes/edges', async () => { + const agent = create(AgentSchema, { + id: 'agent-1', + title: ' Agent One ', + description: '', + config: create(AgentConfigSchema, { name: ' Casey ', role: ' Lead ', model: 'gpt-4' }), + }); + const tool = create(ToolSchema, { + id: 'tool-shell', + name: ' Shell Tool ', + description: 'ignored', + type: ToolType.SHELL_COMMAND, + config: { mode: 'fast' }, + }); + const mcp = create(McpServerSchema, { + id: 'mcp-1', + title: ' MCP Server ', + config: { namespace: 'tools', command: 'run', workdir: '/srv', env: [{ name: 'TOKEN', value: 'secret' }] }, + }); + const detachedMcp = create(McpServerSchema, { + id: 'mcp-2', + title: ' Detached MCP ', + config: { namespace: 'detached', command: 'run2' }, + }); + const workspace = create(WorkspaceConfigurationSchema, { + id: 'workspace-1', + title: ' Workspace ', + config: create(WorkspaceConfigSchema, { + image: 'ubuntu', + platform: WorkspacePlatform.LINUX_AMD64, + initialScript: 'echo hi', + }), + }); + const detachedWorkspace = create(WorkspaceConfigurationSchema, { + id: 'workspace-2', + title: ' Workspace Two ', + config: create(WorkspaceConfigSchema, { + image: 'debian', + platform: WorkspacePlatform.LINUX_ARM64, + }), + }); + const memoryBucket = create(MemoryBucketSchema, { + id: 'memory-1', + title: ' Memory ', + config: create(MemoryBucketConfigSchema, { + scope: MemoryBucketScope.GLOBAL, + collectionPrefix: 'agents', + }), + }); + const attachments = [ + create(AttachmentSchema, { + id: 'attach-agent-tool', + kind: AttachmentKind.AGENT_TOOL, + sourceId: 'agent-1', + targetId: 'tool-shell', + sourceType: EntityType.AGENT, + targetType: EntityType.TOOL, + }), + create(AttachmentSchema, { + id: 'attach-agent-mcp', + kind: AttachmentKind.AGENT_MCP_SERVER, + sourceId: 'agent-1', + targetId: 'mcp-1', + sourceType: EntityType.AGENT, + targetType: EntityType.MCP_SERVER, + }), + create(AttachmentSchema, { + id: 'attach-agent-memory', + kind: AttachmentKind.AGENT_MEMORY_BUCKET, + sourceId: 'agent-1', + targetId: 'memory-1', + sourceType: EntityType.AGENT, + targetType: EntityType.MEMORY_BUCKET, + }), + create(AttachmentSchema, { + id: 'attach-agent-workspace', + kind: AttachmentKind.AGENT_WORKSPACE_CONFIGURATION, + sourceId: 'agent-1', + targetId: 'workspace-1', + sourceType: EntityType.AGENT, + targetType: EntityType.WORKSPACE_CONFIGURATION, + }), + create(AttachmentSchema, { + id: 'attach-mcp-workspace', + kind: AttachmentKind.MCP_SERVER_WORKSPACE_CONFIGURATION, + sourceId: 'mcp-1', + targetId: 'workspace-1', + sourceType: EntityType.MCP_SERVER, + targetType: EntityType.WORKSPACE_CONFIGURATION, + }), + create(AttachmentSchema, { + id: 'attach-mcp-workspace-detached', + kind: AttachmentKind.MCP_SERVER_WORKSPACE_CONFIGURATION, + sourceId: 'mcp-2', + targetId: 'workspace-2', + sourceType: EntityType.MCP_SERVER, + targetType: EntityType.WORKSPACE_CONFIGURATION, + }), + ]; + + const teamsClient = createTeamsClientStub({ + agents: [agent], + tools: [tool], + mcps: [mcp, detachedMcp], + workspaces: [workspace, detachedWorkspace], + memoryBuckets: [memoryBucket], + attachments, + }); + const source = new TeamsGraphSource(teamsClient); + + const { nodes, edges } = await source.load(); + + const nodesById = new Map(nodes.map((node) => [node.id, node])); + expect(nodesById.get('agent-1')).toMatchObject({ + template: 'agent', + config: { title: 'Agent One', model: 'gpt-4', name: 'Casey', role: 'Lead' }, + }); + expect(nodesById.get('tool-shell')).toMatchObject({ + template: 'shellTool', + config: { title: 'Shell Tool', mode: 'fast' }, + }); + expect(nodesById.get('mcp-1')).toMatchObject({ + template: 'mcpServer', + config: { + title: 'MCP Server', + namespace: 'tools', + command: 'run', + workdir: '/srv', + env: [{ name: 'TOKEN', value: 'secret' }], + }, + }); + expect(nodesById.get('mcp-2')).toMatchObject({ + template: 'mcpServer', + config: { title: 'Detached MCP', namespace: 'detached', command: 'run2' }, + }); + expect(nodesById.get('workspace-1')).toMatchObject({ + template: 'workspace', + config: { title: 'Workspace', image: 'ubuntu', platform: 'linux/amd64', initialScript: 'echo hi' }, + }); + expect(nodesById.get('workspace-2')).toMatchObject({ + template: 'workspace', + config: { title: 'Workspace Two', image: 'debian', platform: 'linux/arm64' }, + }); + expect(nodesById.get('memory-1')).toMatchObject({ + template: 'memory', + config: { title: 'Memory', scope: 'global', collectionPrefix: 'agents' }, + }); + expect(nodesById.get('memoryConnector:agent-1:memory-1')).toMatchObject({ template: 'memoryConnector' }); + + const edgeKeys = edges.map(edgeKey); + const expectedEdges = [ + edgeKey({ source: 'agent-1', sourceHandle: 'tools', target: 'tool-shell', targetHandle: '$self' }), + edgeKey({ source: 'agent-1', sourceHandle: 'mcp', target: 'mcp-1', targetHandle: '$self' }), + edgeKey({ source: 'memory-1', sourceHandle: '$self', target: 'memoryConnector:agent-1:memory-1', targetHandle: '$memory' }), + edgeKey({ source: 'memoryConnector:agent-1:memory-1', sourceHandle: '$self', target: 'agent-1', targetHandle: 'memory' }), + edgeKey({ source: 'workspace-1', sourceHandle: '$self', target: 'mcp-1', targetHandle: 'workspace' }), + edgeKey({ source: 'workspace-1', sourceHandle: '$self', target: 'tool-shell', targetHandle: 'workspace' }), + edgeKey({ source: 'workspace-2', sourceHandle: '$self', target: 'mcp-2', targetHandle: 'workspace' }), + ]; + + expect(nodes).toHaveLength(8); + expect(edgeKeys).toHaveLength(expectedEdges.length); + expect(edgeKeys).toEqual(expect.arrayContaining(expectedEdges)); + }); +}); diff --git a/packages/platform-server/src/agents/agents.persistence.service.ts b/packages/platform-server/src/agents/agents.persistence.service.ts index 4d929c5af..ddf1e7d86 100644 --- a/packages/platform-server/src/agents/agents.persistence.service.ts +++ b/packages/platform-server/src/agents/agents.persistence.service.ts @@ -20,6 +20,7 @@ import { coerceRole } from '../llm/services/messages.normalization'; import { ChannelDescriptorSchema, type ChannelDescriptor } from '../messaging/types'; import { RunEventsService } from '../events/run-events.service'; import { EventsBusService } from '../events/events-bus.service'; +import { listAllPages, readString } from '../teams/teamsGrpc.pagination'; import { CallAgentLinkingService } from './call-agent-linking.service'; import { ThreadsMetricsService, type ThreadMetrics } from './threads.metrics.service'; @@ -47,9 +48,6 @@ type ThreadTreeNode = { children?: ThreadTreeNode[]; }; -const DEFAULT_PAGE_SIZE = 100; -const MAX_PAGES = 50; - export class ThreadParentNotFoundError extends Error { constructor() { super('parent_not_found'); @@ -1270,7 +1268,7 @@ export class AgentsPersistenceService { const assignedIds = new Set(); for (const thread of threads) { - const assignedId = this.readString(thread.assignedAgentNodeId ?? undefined); + const assignedId = readString(thread.assignedAgentNodeId); if (assignedId) assignedIds.add(assignedId); } if (assignedIds.size === 0) return descriptors; @@ -1280,13 +1278,13 @@ export class AgentsPersistenceService { const agentById = new Map(); for (const agent of agents) { - const id = this.readString(agent.id); + const id = readString(agent.id); if (!id) continue; agentById.set(id, agent); } for (const thread of threads) { - const assignedId = this.readString(thread.assignedAgentNodeId ?? undefined); + const assignedId = readString(thread.assignedAgentNodeId); if (!assignedId) continue; const agent = agentById.get(assignedId); if (!agent) continue; @@ -1297,34 +1295,15 @@ export class AgentsPersistenceService { } private async listAllTeamsAgents(): Promise { - return this.listAllPages((page, perPage) => + return listAllPages((page, perPage) => this.teamsClient.listAgents(create(ListAgentsRequestSchema, { page, perPage })), ); } - private async listAllPages( - fetchPage: (page: number, perPage: number) => Promise<{ items: T[]; page: number; perPage: number; total: bigint }>, - ): Promise { - const items: T[] = []; - let page = 1; - for (let i = 0; i < MAX_PAGES; i += 1) { - const response = await fetchPage(page, DEFAULT_PAGE_SIZE); - const pageItems = Array.isArray(response.items) ? response.items : []; - items.push(...pageItems); - const total = Number(response.total ?? BigInt(items.length)); - const perPage = response.perPage || DEFAULT_PAGE_SIZE; - const reachedEnd = response.page * perPage >= total; - if (reachedEnd) break; - if (pageItems.length === 0) break; - page = response.page + 1; - } - return items; - } - private buildAgentDescriptor(agent: Agent, fallback: string): AgentDescriptor { - const name = this.readString(agent.config?.name); - const role = this.readString(agent.config?.role); - const title = this.readString(agent.title); + const name = readString(agent.config?.name); + const role = readString(agent.config?.role); + const title = readString(agent.title); const profileFallback = name && role ? `${name} (${role})` : name ?? role; const resolvedTitle = title ?? profileFallback ?? fallback; const descriptor: AgentDescriptor = { title: resolvedTitle }; @@ -1333,12 +1312,6 @@ export class AgentsPersistenceService { return descriptor; } - private readString(value?: string | null): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - private getRunEventDelegate(tx: Prisma.TransactionClient): RunEventDelegate | undefined { const candidate = (tx as { runEvent?: RunEventDelegate }).runEvent; if (!candidate || typeof candidate.findFirst !== 'function') return undefined; diff --git a/packages/platform-server/src/graph/graph.utils.ts b/packages/platform-server/src/graph/graph.utils.ts new file mode 100644 index 000000000..d7948e8d4 --- /dev/null +++ b/packages/platform-server/src/graph/graph.utils.ts @@ -0,0 +1,4 @@ +import type { PersistedGraphEdge } from '../shared/types/graph.types'; + +export const edgeKey = (edge: PersistedGraphEdge): string => + `${edge.source}-${edge.sourceHandle}__${edge.target}-${edge.targetHandle}`; diff --git a/packages/platform-server/src/graph/hybridGraph.repository.ts b/packages/platform-server/src/graph/hybridGraph.repository.ts index c420e3a0e..fe9ea6b69 100644 --- a/packages/platform-server/src/graph/hybridGraph.repository.ts +++ b/packages/platform-server/src/graph/hybridGraph.repository.ts @@ -8,6 +8,7 @@ import type { import type { GraphAuthor } from './graph.repository'; import { GraphRepository } from './graph.repository'; import { FsGraphRepository } from './fsGraph.repository'; +import { edgeKey } from './graph.utils'; import type { TeamsGraphSnapshot } from './teamsGraph.source'; import { TeamsGraphSource } from './teamsGraph.source'; @@ -103,7 +104,7 @@ export class HybridGraphRepository extends GraphRepository { const addEdge = (edge: PersistedGraphEdge): void => { if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return; if (!edge.sourceHandle || !edge.targetHandle) return; - const key = this.edgeKey(edge); + const key = edgeKey(edge); if (seen.has(key)) return; seen.add(key); edges.push({ ...edge, id: key }); @@ -121,7 +122,4 @@ export class HybridGraphRepository extends GraphRepository { return edges; } - private edgeKey(edge: PersistedGraphEdge): string { - return `${edge.source}-${edge.sourceHandle}__${edge.target}-${edge.targetHandle}`; - } } diff --git a/packages/platform-server/src/graph/teamsGraph.source.ts b/packages/platform-server/src/graph/teamsGraph.source.ts index a23650b29..93b355f8b 100644 --- a/packages/platform-server/src/graph/teamsGraph.source.ts +++ b/packages/platform-server/src/graph/teamsGraph.source.ts @@ -3,8 +3,10 @@ import { create, toJson } from '@bufbuild/protobuf'; import { ValueSchema } from '@bufbuild/protobuf/wkt'; import type { PersistedGraphEdge, PersistedGraphNode } from '../shared/types/graph.types'; +import { listAllPages, readString } from '../teams/teamsGrpc.pagination'; import { TEAMS_GRPC_CLIENT } from '../teams/teamsGrpc.token'; import type { TeamsGrpcClient } from '../teams/teamsGrpc.client'; +import { edgeKey } from './graph.utils'; import { Agent, AgentProcessBuffer, @@ -28,9 +30,6 @@ import { WorkspacePlatform, } from '../proto/gen/agynio/api/teams/v1/teams_pb'; -const DEFAULT_PAGE_SIZE = 100; -const MAX_PAGES = 50; - const TOOL_TYPES: ToolType[] = [ ToolType.MANAGE, ToolType.MEMORY, @@ -107,7 +106,7 @@ export class TeamsGraphSource { const addEdge = (source: string, sourceHandle: string, target: string, targetHandle: string): void => { if (!nodeIds.has(source) || !nodeIds.has(target)) return; if (!sourceHandle || !targetHandle) return; - const key = this.edgeKey({ source, sourceHandle, target, targetHandle }); + const key = edgeKey({ source, sourceHandle, target, targetHandle }); if (edgeKeys.has(key)) return; edgeKeys.add(key); edges.push({ id: key, source, sourceHandle, target, targetHandle }); @@ -211,7 +210,7 @@ export class TeamsGraphSource { } private async listAllAgents(): Promise { - return this.listAllPages((page, perPage) => + return listAllPages((page, perPage) => this.teams.listAgents(create(ListAgentsRequestSchema, { page, perPage })), ); } @@ -219,7 +218,7 @@ export class TeamsGraphSource { private async listAllTools(): Promise { const collected = new Map(); for (const type of TOOL_TYPES) { - const items = await this.listAllPages((page, perPage) => + const items = await listAllPages((page, perPage) => this.teams.listTools(create(ListToolsRequestSchema, { type, page, perPage })), ); for (const tool of items) { @@ -232,19 +231,19 @@ export class TeamsGraphSource { } private async listAllMcpServers(): Promise { - return this.listAllPages((page, perPage) => + return listAllPages((page, perPage) => this.teams.listMcpServers(create(ListMcpServersRequestSchema, { page, perPage })), ); } private async listAllWorkspaces(): Promise { - return this.listAllPages((page, perPage) => + return listAllPages((page, perPage) => this.teams.listWorkspaceConfigurations(create(ListWorkspaceConfigurationsRequestSchema, { page, perPage })), ); } private async listAllMemoryBuckets(): Promise { - return this.listAllPages((page, perPage) => + return listAllPages((page, perPage) => this.teams.listMemoryBuckets(create(ListMemoryBucketsRequestSchema, { page, perPage })), ); } @@ -252,7 +251,7 @@ export class TeamsGraphSource { private async listAllAttachments(): Promise { const collected = new Map(); for (const filter of ATTACHMENT_FILTERS) { - const items = await this.listAllPages((page, perPage) => + const items = await listAllPages((page, perPage) => this.teams.listAttachments( create(ListAttachmentsRequestSchema, { kind: filter.kind, @@ -272,31 +271,14 @@ export class TeamsGraphSource { return Array.from(collected.values()); } - private async listAllPages(fetchPage: (page: number, perPage: number) => Promise<{ items: T[]; page: number; perPage: number; total: bigint }>): Promise { - const items: T[] = []; - let page = 1; - for (let i = 0; i < MAX_PAGES; i += 1) { - const response = await fetchPage(page, DEFAULT_PAGE_SIZE); - const pageItems = Array.isArray(response.items) ? response.items : []; - items.push(...pageItems); - const total = Number(response.total ?? BigInt(items.length)); - const perPage = response.perPage || DEFAULT_PAGE_SIZE; - const reachedEnd = response.page * perPage >= total; - if (reachedEnd) break; - if (pageItems.length === 0) break; - page = response.page + 1; - } - return items; - } - private mapAgentConfig(agent: Agent): Record | undefined { const config: Record = {}; - const title = this.readString(agent.title); + const title = readString(agent.title); if (title) config.title = title; const raw = agent.config; if (raw) { - const model = this.readString(raw.model); + const model = readString(raw.model); if (model) config.model = model; const systemPrompt = this.readOptionalString(raw.systemPrompt); if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; @@ -320,9 +302,9 @@ export class TeamsGraphSource { if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; const restrictionMaxInjections = this.readNumber(raw.restrictionMaxInjections); if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; - const name = this.readString(raw.name); + const name = readString(raw.name); if (name) config.name = name; - const role = this.readString(raw.role); + const role = readString(raw.role); if (role) config.role = role; } @@ -331,7 +313,7 @@ export class TeamsGraphSource { private mapToolConfig(tool: Tool): Record | undefined { const config: Record = tool.config ? { ...tool.config } : {}; - const title = this.readString(tool.name); + const title = readString(tool.name); if (title) config.title = title; if (TOOL_TYPES_WITH_NAME.has(tool.type) && title) { config.name = title; @@ -345,15 +327,15 @@ export class TeamsGraphSource { private mapMcpConfig(mcp: McpServer): Record | undefined { const config: Record = {}; - const title = this.readString(mcp.title); + const title = readString(mcp.title); if (title) config.title = title; const raw = mcp.config; if (raw) { - const namespace = this.readString(raw.namespace); + const namespace = readString(raw.namespace); if (namespace) config.namespace = namespace; - const command = this.readString(raw.command); + const command = readString(raw.command); if (command) config.command = command; - const workdir = this.readString(raw.workdir); + const workdir = readString(raw.workdir); if (workdir) config.workdir = workdir; const env = this.mapEnvItems(raw.env); if (env) config.env = env; @@ -379,11 +361,11 @@ export class TeamsGraphSource { private mapWorkspaceConfig(workspace: WorkspaceConfiguration): Record | undefined { const config: Record = {}; - const title = this.readString(workspace.title); + const title = readString(workspace.title); if (title) config.title = title; const raw = workspace.config; if (raw) { - const image = this.readString(raw.image); + const image = readString(raw.image); if (image) config.image = image; const env = this.mapEnvItems(raw.env); if (env) config.env = env; @@ -412,7 +394,7 @@ export class TeamsGraphSource { private mapMemoryBucketConfig(bucket: MemoryBucket): Record | undefined { const config: Record = {}; - const title = this.readString(bucket.title); + const title = readString(bucket.title); if (title) config.title = title; const raw = bucket.config; if (raw) { @@ -474,7 +456,7 @@ export class TeamsGraphSource { if (!items || items.length === 0) return undefined; const mapped: Array<{ name: string; value: string }> = []; for (const item of items) { - const name = this.readString(item.name); + const name = readString(item.name); if (!name) continue; mapped.push({ name, value: item.value }); } @@ -503,19 +485,13 @@ export class TeamsGraphSource { return value; } - private readString(value?: string): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - private readOptionalString(value?: string): string | undefined { if (typeof value !== 'string') return undefined; return value; } private normalizeId(value?: string): string | undefined { - return this.readString(value); + return readString(value); } private addToSet(map: Map>, key: string, value: string): void { @@ -567,10 +543,6 @@ export class TeamsGraphSource { } } - private edgeKey(edge: PersistedGraphEdge): string { - return `${edge.source}-${edge.sourceHandle}__${edge.target}-${edge.targetHandle}`; - } - private memoryConnectorId(agentId: string, memoryId: string): string { return `memoryConnector:${agentId}:${memoryId}`; } diff --git a/packages/platform-server/src/teams/teamsGrpc.pagination.ts b/packages/platform-server/src/teams/teamsGrpc.pagination.ts new file mode 100644 index 000000000..a7410e124 --- /dev/null +++ b/packages/platform-server/src/teams/teamsGrpc.pagination.ts @@ -0,0 +1,37 @@ +export const DEFAULT_PAGE_SIZE = 100; +export const MAX_PAGES = 50; + +export type PaginatedResponse = { + items: T[]; + page: number; + perPage: number; + total: bigint; +}; + +export const listAllPages = async ( + fetchPage: (page: number, perPage: number) => Promise>, +): Promise => { + const items: T[] = []; + let page = 1; + for (let i = 0; i < MAX_PAGES; i += 1) { + const response = await fetchPage(page, DEFAULT_PAGE_SIZE); + const pageItems = response.items; + items.push(...pageItems); + const perPage = response.perPage; + if (perPage === 0) { + throw new Error('teams_pagination_per_page_zero'); + } + const total = Number(response.total); + const reachedEnd = response.page * perPage >= total; + if (reachedEnd) break; + if (pageItems.length === 0) break; + page = response.page + 1; + } + return items; +}; + +export const readString = (value?: string | null): string | undefined => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; From 5f2615ec0c2cc61576c0f31a38d7e5cef227ffa5 Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Sat, 14 Mar 2026 13:50:32 +0000 Subject: [PATCH 24/43] refactor(platform): remove docker-runner (#1399) * refactor(platform): drop docker-runner * feat: add secret provider entities (#1394) * feat(entities): add secret provider kinds * refactor(secrets): move to gateway CRUD * refactor(ui): extract secrets helpers * fix(secrets): use token pagination * feat(ui): add secret upsert pages * fix(ui): stabilize dropdown selections * fix(ui): align secret form sizes * test: centralize docker client stubs --- buf.gen.yaml | 4 + .../__tests__/workload.grpc.mapper.test.ts | 58 +++++ ...ntainers.delete.docker.integration.test.ts | 1 - .../containers.delete.integration.test.ts | 2 +- ...iners.fullstack.docker.integration.test.ts | 2 +- .../__tests__/helpers/docker.e2e.ts | 9 +- ...ec.cancellation.docker.integration.test.ts | 5 +- .../workspace.reuse.integration.test.ts | 7 +- pnpm-lock.yaml | 198 ------------------ 9 files changed, 79 insertions(+), 207 deletions(-) create mode 100644 packages/docker-runner/__tests__/workload.grpc.mapper.test.ts diff --git a/buf.gen.yaml b/buf.gen.yaml index 8d85d3147..845100f87 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -12,3 +12,7 @@ plugins: out: packages/docker-runner/src/proto/gen opt: - target=ts + - plugin: buf.build/bufbuild/connect-es + out: packages/platform-server/src/proto/gen + opt: + - target=ts diff --git a/packages/docker-runner/__tests__/workload.grpc.mapper.test.ts b/packages/docker-runner/__tests__/workload.grpc.mapper.test.ts new file mode 100644 index 000000000..f76431566 --- /dev/null +++ b/packages/docker-runner/__tests__/workload.grpc.mapper.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + containerOptsToStartWorkloadRequest, + startWorkloadRequestToContainerOpts, +} from '../src/contracts/workload.grpc'; +import type { ContainerOpts } from '../src/lib/types'; + +describe('workload gRPC mapping', () => { + it('round-trips container options with mounts and metadata', () => { + const opts: ContainerOpts = { + image: 'node:18', + name: 'worker-main', + cmd: ['npm', 'run', 'start'], + entrypoint: '/bin/sh', + env: { + NODE_ENV: 'production', + API_TOKEN: 'secret', + }, + workingDir: '/workspace', + autoRemove: true, + binds: ['ha_ws_123:/workspace', '/var/run/docker.sock:/var/run/docker.sock:ro,z'], + networkMode: 'container:abc123', + tty: true, + labels: { + 'hautech.ai/run-id': 'run-42', + }, + platform: 'linux/amd64', + privileged: true, + anonymousVolumes: ['/var/lib/docker'], + createExtras: { + HostConfig: { + NanoCPUs: 2_000_000_000, + Memory: 512 * 1024 * 1024, + }, + }, + ttlSeconds: 1800, + }; + + const request = containerOptsToStartWorkloadRequest(opts); + expect(request.main?.image).toBe('node:18'); + expect(request.volumes).toHaveLength(3); + + const rebuilt = startWorkloadRequestToContainerOpts(request); + expect(rebuilt).toEqual(opts); + }); + + it('retains bind option ordering and readonly flags', () => { + const opts: ContainerOpts = { + image: 'alpine:3', + binds: ['data:/data:ro,z'], + }; + + const request = containerOptsToStartWorkloadRequest(opts); + const rebuilt = startWorkloadRequestToContainerOpts(request); + + expect(rebuilt.binds).toEqual(['data:/data:ro,z']); + }); +}); diff --git a/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts b/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts index cafaa67af..2a0a65eb0 100644 --- a/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts +++ b/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts @@ -24,7 +24,6 @@ import { runnerSecretMissing, socketMissing, startDockerRunner, - startDockerRunnerProcess, startPostgres, runPrismaMigrations, type RunnerHandle, diff --git a/packages/platform-server/__tests__/containers.delete.integration.test.ts b/packages/platform-server/__tests__/containers.delete.integration.test.ts index f236c76a8..155e110e9 100644 --- a/packages/platform-server/__tests__/containers.delete.integration.test.ts +++ b/packages/platform-server/__tests__/containers.delete.integration.test.ts @@ -23,6 +23,7 @@ import { TerminalSessionsService } from '../src/infra/container/terminal.session import { ContainerThreadTerminationService } from '../src/infra/container/containerThreadTermination.service'; import { ContainerEventProcessor } from '../src/infra/container/containerEvent.processor'; import { registerTestConfig, clearTestConfig } from './helpers/config'; +import { createDockerClientStub } from './helpers/dockerClient.stub'; import type { Prisma, PrismaClient } from '@prisma/client'; import { DockerRunnerStatusService } from '../src/infra/container/dockerRunnerStatus.service'; import { DockerRunnerConnectivityMonitor } from '../src/infra/container/dockerRunnerConnectivity.monitor'; @@ -144,7 +145,6 @@ const createDockerClientStub = (): DockerClient => ({ inspectContainer: vi.fn().mockResolvedValue({} as any), getEventsStream: vi.fn().mockResolvedValue({ on: vi.fn(), off: vi.fn() } as any), } as unknown as DockerClient); - const createContainerRegistryStub = () => ({ ensureIndexes: vi.fn().mockResolvedValue(undefined), registerStart: vi.fn(), diff --git a/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts b/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts index cef53f487..6a49a8e74 100644 --- a/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts +++ b/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts @@ -25,7 +25,7 @@ import { runnerAddressMissing, runnerSecretMissing, socketMissing, - startDockerRunnerProcess, + startDockerRunner, startPostgres, runPrismaMigrations, waitFor, diff --git a/packages/platform-server/__tests__/helpers/docker.e2e.ts b/packages/platform-server/__tests__/helpers/docker.e2e.ts index f14330311..7a8f8c623 100644 --- a/packages/platform-server/__tests__/helpers/docker.e2e.ts +++ b/packages/platform-server/__tests__/helpers/docker.e2e.ts @@ -13,10 +13,17 @@ import { createRunnerGrpcServer } from '../../../docker-runner/src/service/grpc/ import { ContainerService, NonceCache, buildAuthHeaders } from '../../../docker-runner/src'; import { ReadyRequestSchema, RunnerService } from '../../src/proto/gen/agynio/api/runner/v1/runner_pb.js'; -export const RUNNER_SECRET = 'docker-e2e-secret'; +export const RUNNER_SECRET = process.env.DOCKER_RUNNER_SHARED_SECRET ?? ''; export const DEFAULT_SOCKET = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; export const hasTcpDocker = Boolean(process.env.DOCKER_HOST); export const socketMissing = !fs.existsSync(DEFAULT_SOCKET); +const runnerHost = process.env.DOCKER_RUNNER_GRPC_HOST ?? process.env.DOCKER_RUNNER_HOST; +const runnerPort = process.env.DOCKER_RUNNER_GRPC_PORT ?? process.env.DOCKER_RUNNER_PORT; +export const runnerAddress = + process.env.DOCKER_RUNNER_GRPC_ADDRESS ?? (runnerHost && runnerPort ? `${runnerHost}:${runnerPort}` : undefined); +export const runnerAddressMissing = !runnerAddress; +export const runnerSecretMissing = !RUNNER_SECRET; +const readinessNonceCache = new NonceCache(); export type RunnerHandle = { grpcAddress: string; diff --git a/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts b/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts index 3ebf53936..5ad7cb42d 100644 --- a/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts +++ b/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts @@ -5,15 +5,16 @@ import { Http2SessionManager } from '@connectrpc/connect-node'; import { RunnerGrpcClient } from '../src/infra/container/runnerGrpc.client'; import { - DEFAULT_SOCKET, RUNNER_SECRET, hasTcpDocker, + runnerAddressMissing, + runnerSecretMissing, socketMissing, startDockerRunner, type RunnerHandle, } from './helpers/docker.e2e'; -const shouldSkip = process.env.SKIP_RUNNER_EXEC_E2E === '1'; +const shouldSkip = process.env.SKIP_RUNNER_EXEC_E2E === '1' || runnerAddressMissing || runnerSecretMissing; const describeOrSkip = shouldSkip || (socketMissing && !hasTcpDocker) ? describe.skip : describe; describeOrSkip('runner gRPC exec cancellation integration', () => { diff --git a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts index a849e0d1a..c015bfdc7 100644 --- a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts +++ b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts @@ -14,17 +14,18 @@ import { PrismaService } from '../../src/core/services/prisma.service'; import { registerTestConfig, clearTestConfig } from '../helpers/config'; import { RUNNER_SECRET, - DEFAULT_SOCKET, hasTcpDocker, + runnerAddressMissing, + runnerSecretMissing, socketMissing, - startDockerRunnerProcess, + startDockerRunner, startPostgres, runPrismaMigrations, type RunnerHandle, type PostgresHandle, } from '../helpers/docker.e2e'; -const shouldSkip = process.env.SKIP_WORKSPACE_REUSE_E2E === '1'; +const shouldSkip = process.env.SKIP_WORKSPACE_REUSE_E2E === '1' || runnerAddressMissing || runnerSecretMissing; const describeOrSkip = shouldSkip || (socketMissing && !hasTcpDocker) ? describe.skip : describe.sequential; describeOrSkip('Docker workspace reuse lifecycle', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5982db56c..e63f7591f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,9 +133,6 @@ importers: packages/platform-server: dependencies: - '@agyn/docker-runner': - specifier: workspace:* - version: link:../docker-runner '@agyn/json-schema-to-zod': specifier: workspace:* version: link:../json-schema-to-zod @@ -913,9 +910,6 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} - '@balena/dockerignore@1.0.2': - resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} - '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1463,11 +1457,6 @@ packages: resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} engines: {node: '>=12.10.0'} - '@grpc/proto-loader@0.7.15': - resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} - engines: {node: '>=6'} - hasBin: true - '@grpc/proto-loader@0.8.0': resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} engines: {node: '>=6'} @@ -3828,12 +3817,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/docker-modem@3.0.6': - resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} - - '@types/dockerode@3.3.44': - resolution: {integrity: sha512-fUpIHlsbYpxAJb285xx3vp7q5wf5mjqSn3cYwl/MhiM+DB99OdO5sOCPlO0PjO+TyOtphPs7tMVLU/RtOo/JjA==} - '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} @@ -3888,9 +3871,6 @@ packages: '@types/mustache@4.2.6': resolution: {integrity: sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==} - '@types/node@18.19.127': - resolution: {integrity: sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==} - '@types/node@20.19.19': resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} @@ -3921,9 +3901,6 @@ packages: resolution: {integrity: sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==} deprecated: This is a stub types definition. socket.io provides its own type definitions, so you do not need this installed. - '@types/ssh2@1.15.5': - resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} - '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -4318,9 +4295,6 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - asn1@0.2.6: - resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4413,18 +4387,12 @@ packages: resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} hasBin: true - bcrypt-pbkdf@1.0.2: - resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -4453,13 +4421,6 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - buildcheck@0.0.6: - resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} - engines: {node: '>=10.0.0'} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4566,9 +4527,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4736,10 +4694,6 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} - cpu-features@0.0.10: - resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} - engines: {node: '>=10.0.0'} - create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4974,14 +4928,6 @@ packages: dnd-core@16.0.1: resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} - docker-modem@5.0.6: - resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} - engines: {node: '>= 8.0'} - - dockerode@4.0.8: - resolution: {integrity: sha512-HdPBprWmwfHMHi12AVIFDhXIqIS+EpiOVkZaAZxgML4xf5McqEZjJZtahTPkLDxWOt84ApfWPAH9EoQwOiaAIQ==} - engines: {node: '>= 8.0'} - doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -5491,9 +5437,6 @@ packages: fromentries@1.3.2: resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-exists-sync@0.1.0: resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} engines: {node: '>=0.10.0'} @@ -6674,9 +6617,6 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -6716,9 +6656,6 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nan@2.23.0: - resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7704,9 +7641,6 @@ packages: spawnd@5.0.0: resolution: {integrity: sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA==} - split-ca@1.0.1: - resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} - split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -7714,10 +7648,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - ssh2@1.17.0: - resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} - engines: {node: '>=10.16.0'} - stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -7890,13 +7820,6 @@ packages: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -8033,9 +7956,6 @@ packages: tw-animate-css@1.3.8: resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} - tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8092,9 +8012,6 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8774,8 +8691,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@balena/dockerignore@1.0.2': {} - '@bcoe/v8-coverage@0.2.3': {} '@borewit/text-codec@0.1.1': {} @@ -9244,13 +9159,6 @@ snapshots: '@grpc/proto-loader': 0.8.0 '@js-sdsl/ordered-map': 4.4.2 - '@grpc/proto-loader@0.7.15': - dependencies: - lodash.camelcase: 4.3.0 - long: 5.3.2 - protobufjs: 7.5.4 - yargs: 17.7.2 - '@grpc/proto-loader@0.8.0': dependencies: lodash.camelcase: 4.3.0 @@ -11985,17 +11893,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/docker-modem@3.0.6': - dependencies: - '@types/node': 24.5.2 - '@types/ssh2': 1.15.5 - - '@types/dockerode@3.3.44': - dependencies: - '@types/docker-modem': 3.0.6 - '@types/node': 24.5.2 - '@types/ssh2': 1.15.5 - '@types/doctrine@0.0.9': {} '@types/estree-jsx@1.0.5': @@ -12049,10 +11946,6 @@ snapshots: '@types/mustache@4.2.6': {} - '@types/node@18.19.127': - dependencies: - undici-types: 5.26.5 - '@types/node@20.19.19': dependencies: undici-types: 6.21.0 @@ -12087,10 +11980,6 @@ snapshots: - supports-color - utf-8-validate - '@types/ssh2@1.15.5': - dependencies: - '@types/node': 18.19.127 - '@types/stack-utils@2.0.3': {} '@types/statuses@2.0.6': {} @@ -12538,10 +12427,6 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - asn1@0.2.6: - dependencies: - safer-buffer: 2.1.2 - assertion-error@2.0.1: {} ast-types@0.16.1: @@ -12649,22 +12534,12 @@ snapshots: baseline-browser-mapping@2.8.6: {} - bcrypt-pbkdf@1.0.2: - dependencies: - tweetnacl: 0.14.5 - before-after-hook@4.0.0: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -12708,14 +12583,6 @@ snapshots: buffer-from@1.1.2: {} - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - buildcheck@0.0.6: - optional: true - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -12818,8 +12685,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@1.1.4: {} - chownr@3.0.0: {} chromatic@13.3.4: {} @@ -12960,12 +12825,6 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cpu-features@0.0.10: - dependencies: - buildcheck: 0.0.6 - nan: 2.23.0 - optional: true - create-require@1.1.1: {} cross-spawn@7.0.6: @@ -13179,27 +13038,6 @@ snapshots: '@react-dnd/invariant': 4.0.2 redux: 4.2.1 - docker-modem@5.0.6: - dependencies: - debug: 4.4.3 - readable-stream: 3.6.2 - split-ca: 1.0.1 - ssh2: 1.17.0 - transitivePeerDependencies: - - supports-color - - dockerode@4.0.8: - dependencies: - '@balena/dockerignore': 1.0.2 - '@grpc/grpc-js': 1.14.0 - '@grpc/proto-loader': 0.7.15 - docker-modem: 5.0.6 - protobufjs: 7.5.4 - tar-fs: 2.1.4 - uuid: 10.0.0 - transitivePeerDependencies: - - supports-color - doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -13819,8 +13657,6 @@ snapshots: fromentries@1.3.2: {} - fs-constants@1.0.0: {} - fs-exists-sync@0.1.0: {} fs.realpath@1.0.0: {} @@ -15423,8 +15259,6 @@ snapshots: dependencies: minipass: 7.1.2 - mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -15497,9 +15331,6 @@ snapshots: mute-stream@2.0.0: {} - nan@2.23.0: - optional: true - nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -16641,20 +16472,10 @@ snapshots: transitivePeerDependencies: - supports-color - split-ca@1.0.1: {} - split2@4.2.0: {} sprintf-js@1.0.3: {} - ssh2@1.17.0: - dependencies: - asn1: 0.2.6 - bcrypt-pbkdf: 1.0.2 - optionalDependencies: - cpu-features: 0.0.10 - nan: 2.23.0 - stack-trace@0.0.10: {} stack-utils@2.0.6: @@ -16826,21 +16647,6 @@ snapshots: tapable@2.2.3: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.3 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -16976,8 +16782,6 @@ snapshots: tw-animate-css@1.3.8: {} - tweetnacl@0.14.5: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -17023,8 +16827,6 @@ snapshots: uint8array-extras@1.5.0: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} undici-types@7.12.0: {} From 619b7dc484020ffb4ab3b6140c08bd60eb73737c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 25/43] feat(platform-server): add teams grpc client --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 3223e425d..a5e38d02d 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -16,6 +16,7 @@ const trackedEnvKeys = [ 'AGENTS_DEPLOYMENT', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From bc73fbf250d3c2c2f34350b51126f9aceb22a578 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 11 Mar 2026 05:17:05 +0000 Subject: [PATCH 26/43] refactor(grpc): migrate runner clients --- buf.gen.yaml | 8 + .../docker-runner/src/service/grpc/server.ts | 48 +- .../containers.delete.integration.test.ts | 1 - ...ec.cancellation.docker.integration.test.ts | 5 +- .../workspace.reuse.integration.test.ts | 7 +- packages/platform-server/package.json | 2 +- packages/platform-server/src/proto/grpc.ts | 409 ------------------ pnpm-lock.yaml | 198 +++++++++ 8 files changed, 214 insertions(+), 464 deletions(-) delete mode 100644 packages/platform-server/src/proto/grpc.ts diff --git a/buf.gen.yaml b/buf.gen.yaml index 845100f87..234b9da04 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -16,3 +16,11 @@ plugins: out: packages/platform-server/src/proto/gen opt: - target=ts + - plugin: buf.build/bufbuild/es + out: packages/docker-runner/src/proto/gen + opt: + - target=ts + - plugin: buf.build/bufbuild/connect-es + out: packages/docker-runner/src/proto/gen + opt: + - target=ts diff --git a/packages/docker-runner/src/service/grpc/server.ts b/packages/docker-runner/src/service/grpc/server.ts index c7916e52a..837b88fcf 100644 --- a/packages/docker-runner/src/service/grpc/server.ts +++ b/packages/docker-runner/src/service/grpc/server.ts @@ -81,13 +81,6 @@ type ExecutionContext = { const activeExecutions = new Map(); -const shouldDebugExec = process.env.DEBUG_RUNNER_EXEC === '1'; - -const logExec = (message: string, details: Record = {}) => { - if (!shouldDebugExec) return; - console.info(`[runner exec] ${message}`, details); -}; - const runnerServicePath = (method: keyof typeof RunnerService.method): string => `/${RunnerService.typeName}/${RunnerService.method[method].name}`; @@ -868,11 +861,6 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { const closeResponses = () => { if (closed) return; - logExec('closeResponses', { - executionId: ctx?.executionId, - requestId: ctx?.requestId, - finished: ctx?.finished, - }); closed = true; responseQueue.end(); }; @@ -883,12 +871,6 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { const finish = async (target: ExecutionContext, reason: ExecExitReason, killed = false) => { if (!target || target.finished) return; - logExec('finish', { - executionId: target.executionId, - requestId: target.requestId, - reason, - killed, - }); target.finished = true; target.reason = reason; target.killed = killed; @@ -1185,46 +1167,26 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { await handleRequest(req); if (closed || ctx?.finished) break; } - logExec('handleRequests completed', { - executionId: ctx?.executionId, - requestId: ctx?.requestId, - finished: ctx?.finished, - closed, - }); if (!ctx || closed) { closeResponses(); return; } if (ctx.finished) { + closeResponses(); return; } return; } catch { - logExec('handleRequests error', { - executionId: ctx?.executionId, - requestId: ctx?.requestId, - finished: ctx?.finished, - closed, - }); - if (!ctx || closed) { + if (!ctx || ctx.finished || closed) { closeResponses(); return; } - if (ctx.finished) { - return; - } clearExecutionTimers(ctx); await finish(ctx, ExecExitReason.RUNNER_ERROR, ctx.killed); } })(); const onAbort = () => { - logExec('context aborted', { - executionId: ctx?.executionId, - requestId: ctx?.requestId, - finished: ctx?.finished, - closed, - }); if (!ctx || ctx.finished || closed) return; ctx.cancelRequested = true; clearExecutionTimers(ctx); @@ -1237,12 +1199,6 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { yield response; } } finally { - logExec('response loop closing', { - executionId: ctx?.executionId, - requestId: ctx?.requestId, - finished: ctx?.finished, - closed, - }); context.signal.removeEventListener('abort', onAbort); closeResponses(); await handleRequests.catch(() => undefined); diff --git a/packages/platform-server/__tests__/containers.delete.integration.test.ts b/packages/platform-server/__tests__/containers.delete.integration.test.ts index 155e110e9..a98725228 100644 --- a/packages/platform-server/__tests__/containers.delete.integration.test.ts +++ b/packages/platform-server/__tests__/containers.delete.integration.test.ts @@ -23,7 +23,6 @@ import { TerminalSessionsService } from '../src/infra/container/terminal.session import { ContainerThreadTerminationService } from '../src/infra/container/containerThreadTermination.service'; import { ContainerEventProcessor } from '../src/infra/container/containerEvent.processor'; import { registerTestConfig, clearTestConfig } from './helpers/config'; -import { createDockerClientStub } from './helpers/dockerClient.stub'; import type { Prisma, PrismaClient } from '@prisma/client'; import { DockerRunnerStatusService } from '../src/infra/container/dockerRunnerStatus.service'; import { DockerRunnerConnectivityMonitor } from '../src/infra/container/dockerRunnerConnectivity.monitor'; diff --git a/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts b/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts index 5ad7cb42d..3ebf53936 100644 --- a/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts +++ b/packages/platform-server/__tests__/runner.exec.cancellation.docker.integration.test.ts @@ -5,16 +5,15 @@ import { Http2SessionManager } from '@connectrpc/connect-node'; import { RunnerGrpcClient } from '../src/infra/container/runnerGrpc.client'; import { + DEFAULT_SOCKET, RUNNER_SECRET, hasTcpDocker, - runnerAddressMissing, - runnerSecretMissing, socketMissing, startDockerRunner, type RunnerHandle, } from './helpers/docker.e2e'; -const shouldSkip = process.env.SKIP_RUNNER_EXEC_E2E === '1' || runnerAddressMissing || runnerSecretMissing; +const shouldSkip = process.env.SKIP_RUNNER_EXEC_E2E === '1'; const describeOrSkip = shouldSkip || (socketMissing && !hasTcpDocker) ? describe.skip : describe; describeOrSkip('runner gRPC exec cancellation integration', () => { diff --git a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts index c015bfdc7..a849e0d1a 100644 --- a/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts +++ b/packages/platform-server/__tests__/workspace/workspace.reuse.integration.test.ts @@ -14,18 +14,17 @@ import { PrismaService } from '../../src/core/services/prisma.service'; import { registerTestConfig, clearTestConfig } from '../helpers/config'; import { RUNNER_SECRET, + DEFAULT_SOCKET, hasTcpDocker, - runnerAddressMissing, - runnerSecretMissing, socketMissing, - startDockerRunner, + startDockerRunnerProcess, startPostgres, runPrismaMigrations, type RunnerHandle, type PostgresHandle, } from '../helpers/docker.e2e'; -const shouldSkip = process.env.SKIP_WORKSPACE_REUSE_E2E === '1' || runnerAddressMissing || runnerSecretMissing; +const shouldSkip = process.env.SKIP_WORKSPACE_REUSE_E2E === '1'; const describeOrSkip = shouldSkip || (socketMissing && !hasTcpDocker) ? describe.skip : describe.sequential; describeOrSkip('Docker workspace reuse lifecycle', () => { diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 522352996..fcffa38e5 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -12,7 +12,7 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", - "test:litellm": "vitest run __tests__/integration/litellm.*.test.ts", + "test:litellm": "vitest run __tests__/integration/litellm.integration.test.ts", "test:watch": "vitest", "lint": "pnpm run prisma:generate && eslint .", "prepare": "prisma generate", diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts deleted file mode 100644 index 5a3d37cdb..000000000 --- a/packages/platform-server/src/proto/grpc.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { makeGenericClientConstructor } from '@grpc/grpc-js'; -import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; -import { toBinary, fromBinary } from '@bufbuild/protobuf'; -import type { DescMessage } from '@bufbuild/protobuf'; -import { EmptySchema } from '@bufbuild/protobuf/wkt'; -import { - CancelExecutionRequestSchema, - CancelExecutionResponseSchema, - ExecRequestSchema, - ExecResponseSchema, - FindWorkloadsByLabelsRequestSchema, - FindWorkloadsByLabelsResponseSchema, - GetWorkloadLabelsRequestSchema, - GetWorkloadLabelsResponseSchema, - InspectWorkloadRequestSchema, - InspectWorkloadResponseSchema, - ListWorkloadsByVolumeRequestSchema, - ListWorkloadsByVolumeResponseSchema, - PutArchiveRequestSchema, - PutArchiveResponseSchema, - ReadyRequestSchema, - ReadyResponseSchema, - RemoveVolumeRequestSchema, - RemoveVolumeResponseSchema, - RemoveWorkloadRequestSchema, - RemoveWorkloadResponseSchema, - StartWorkloadRequestSchema, - StartWorkloadResponseSchema, - StopWorkloadRequestSchema, - StopWorkloadResponseSchema, - StreamEventsRequestSchema, - StreamEventsResponseSchema, - StreamWorkloadLogsRequestSchema, - StreamWorkloadLogsResponseSchema, - TouchWorkloadRequestSchema, - TouchWorkloadResponseSchema, -} from './gen/agynio/api/runner/v1/runner_pb.js'; -import { - AgentCreateRequestSchema, - AgentSchema, - AgentUpdateRequestSchema, - AttachmentCreateRequestSchema, - AttachmentSchema, - DeleteAgentRequestSchema, - DeleteAttachmentRequestSchema, - DeleteMcpServerRequestSchema, - DeleteMemoryBucketRequestSchema, - DeleteToolRequestSchema, - DeleteWorkspaceConfigurationRequestSchema, - GetAgentRequestSchema, - GetMcpServerRequestSchema, - GetMemoryBucketRequestSchema, - GetToolRequestSchema, - GetWorkspaceConfigurationRequestSchema, - ListAgentsRequestSchema, - ListAttachmentsRequestSchema, - ListMcpServersRequestSchema, - ListMemoryBucketsRequestSchema, - ListToolsRequestSchema, - ListWorkspaceConfigurationsRequestSchema, - McpServerCreateRequestSchema, - McpServerSchema, - McpServerUpdateRequestSchema, - MemoryBucketCreateRequestSchema, - MemoryBucketSchema, - MemoryBucketUpdateRequestSchema, - PaginatedAgentsSchema, - PaginatedAttachmentsSchema, - PaginatedMcpServersSchema, - PaginatedMemoryBucketsSchema, - PaginatedToolsSchema, - PaginatedWorkspaceConfigurationsSchema, - ToolCreateRequestSchema, - ToolSchema, - ToolUpdateRequestSchema, - WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationSchema, - WorkspaceConfigurationUpdateRequestSchema, -} from './gen/agynio/api/teams/v1/teams_pb.js'; - -const unaryDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: false, - responseStream: false, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -const serverStreamDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: false, - responseStream: true, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -const bidiDefinition = ( - path: string, - input: DescMessage, - output: DescMessage, -): MethodDefinition => ({ - path, - requestStream: true, - responseStream: true, - requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), - responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), - requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), - responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), - originalName: path.split('/').pop() ?? path, -}); - -export const RUNNER_SERVICE_READY_PATH = '/agynio.api.runner.v1.RunnerService/Ready'; -export const RUNNER_SERVICE_START_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StartWorkload'; -export const RUNNER_SERVICE_STOP_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StopWorkload'; -export const RUNNER_SERVICE_REMOVE_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/RemoveWorkload'; -export const RUNNER_SERVICE_INSPECT_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/InspectWorkload'; -export const RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/GetWorkloadLabels'; -export const RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/FindWorkloadsByLabels'; -export const RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/ListWorkloadsByVolume'; -export const RUNNER_SERVICE_REMOVE_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/RemoveVolume'; -export const RUNNER_SERVICE_TOUCH_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/TouchWorkload'; -export const RUNNER_SERVICE_PUT_ARCHIVE_PATH = '/agynio.api.runner.v1.RunnerService/PutArchive'; -export const RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH = '/agynio.api.runner.v1.RunnerService/StreamWorkloadLogs'; -export const RUNNER_SERVICE_STREAM_EVENTS_PATH = '/agynio.api.runner.v1.RunnerService/StreamEvents'; -export const RUNNER_SERVICE_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/Exec'; -export const RUNNER_SERVICE_CANCEL_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/CancelExecution'; - -export const runnerServiceGrpcDefinition: ServiceDefinition = { - ready: unaryDefinition( - RUNNER_SERVICE_READY_PATH, - ReadyRequestSchema, - ReadyResponseSchema, - ), - startWorkload: unaryDefinition( - RUNNER_SERVICE_START_WORKLOAD_PATH, - StartWorkloadRequestSchema, - StartWorkloadResponseSchema, - ), - stopWorkload: unaryDefinition( - RUNNER_SERVICE_STOP_WORKLOAD_PATH, - StopWorkloadRequestSchema, - StopWorkloadResponseSchema, - ), - removeWorkload: unaryDefinition( - RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, - RemoveWorkloadRequestSchema, - RemoveWorkloadResponseSchema, - ), - inspectWorkload: unaryDefinition( - RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, - InspectWorkloadRequestSchema, - InspectWorkloadResponseSchema, - ), - getWorkloadLabels: unaryDefinition( - RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH, - GetWorkloadLabelsRequestSchema, - GetWorkloadLabelsResponseSchema, - ), - findWorkloadsByLabels: unaryDefinition( - RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH, - FindWorkloadsByLabelsRequestSchema, - FindWorkloadsByLabelsResponseSchema, - ), - listWorkloadsByVolume: unaryDefinition( - RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH, - ListWorkloadsByVolumeRequestSchema, - ListWorkloadsByVolumeResponseSchema, - ), - removeVolume: unaryDefinition( - RUNNER_SERVICE_REMOVE_VOLUME_PATH, - RemoveVolumeRequestSchema, - RemoveVolumeResponseSchema, - ), - touchWorkload: unaryDefinition( - RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, - TouchWorkloadRequestSchema, - TouchWorkloadResponseSchema, - ), - putArchive: unaryDefinition( - RUNNER_SERVICE_PUT_ARCHIVE_PATH, - PutArchiveRequestSchema, - PutArchiveResponseSchema, - ), - streamWorkloadLogs: serverStreamDefinition( - RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH, - StreamWorkloadLogsRequestSchema, - StreamWorkloadLogsResponseSchema, - ), - streamEvents: serverStreamDefinition( - RUNNER_SERVICE_STREAM_EVENTS_PATH, - StreamEventsRequestSchema, - StreamEventsResponseSchema, - ), - exec: bidiDefinition( - RUNNER_SERVICE_EXEC_PATH, - ExecRequestSchema, - ExecResponseSchema, - ), - cancelExecution: unaryDefinition( - RUNNER_SERVICE_CANCEL_EXEC_PATH, - CancelExecutionRequestSchema, - CancelExecutionResponseSchema, - ), -}; - -export const RunnerServiceGrpcClient = makeGenericClientConstructor( - runnerServiceGrpcDefinition, - 'agynio.api.runner.v1.RunnerService', -); - -export type RunnerServiceGrpcClientInstance = InstanceType; - -export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; -export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; -export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; -export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; -export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; -export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; -export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; -export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; -export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; -export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; -export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; -export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; -export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; -export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; -export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; -export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = - '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; -export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; -export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; -export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; -export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = - '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; -export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; -export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; -export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; -export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; -export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; -export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; -export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; -export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; - -export const teamsServiceGrpcDefinition: ServiceDefinition = { - listAgents: unaryDefinition( - TEAMS_SERVICE_LIST_AGENTS_PATH, - ListAgentsRequestSchema, - PaginatedAgentsSchema, - ), - createAgent: unaryDefinition( - TEAMS_SERVICE_CREATE_AGENT_PATH, - AgentCreateRequestSchema, - AgentSchema, - ), - getAgent: unaryDefinition( - TEAMS_SERVICE_GET_AGENT_PATH, - GetAgentRequestSchema, - AgentSchema, - ), - updateAgent: unaryDefinition( - TEAMS_SERVICE_UPDATE_AGENT_PATH, - AgentUpdateRequestSchema, - AgentSchema, - ), - deleteAgent: unaryDefinition( - TEAMS_SERVICE_DELETE_AGENT_PATH, - DeleteAgentRequestSchema, - EmptySchema, - ), - listTools: unaryDefinition( - TEAMS_SERVICE_LIST_TOOLS_PATH, - ListToolsRequestSchema, - PaginatedToolsSchema, - ), - createTool: unaryDefinition( - TEAMS_SERVICE_CREATE_TOOL_PATH, - ToolCreateRequestSchema, - ToolSchema, - ), - getTool: unaryDefinition( - TEAMS_SERVICE_GET_TOOL_PATH, - GetToolRequestSchema, - ToolSchema, - ), - updateTool: unaryDefinition( - TEAMS_SERVICE_UPDATE_TOOL_PATH, - ToolUpdateRequestSchema, - ToolSchema, - ), - deleteTool: unaryDefinition( - TEAMS_SERVICE_DELETE_TOOL_PATH, - DeleteToolRequestSchema, - EmptySchema, - ), - listMcpServers: unaryDefinition( - TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, - ListMcpServersRequestSchema, - PaginatedMcpServersSchema, - ), - createMcpServer: unaryDefinition( - TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, - McpServerCreateRequestSchema, - McpServerSchema, - ), - getMcpServer: unaryDefinition( - TEAMS_SERVICE_GET_MCP_SERVER_PATH, - GetMcpServerRequestSchema, - McpServerSchema, - ), - updateMcpServer: unaryDefinition( - TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, - McpServerUpdateRequestSchema, - McpServerSchema, - ), - deleteMcpServer: unaryDefinition( - TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, - DeleteMcpServerRequestSchema, - EmptySchema, - ), - listWorkspaceConfigurations: unaryDefinition( - TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, - ListWorkspaceConfigurationsRequestSchema, - PaginatedWorkspaceConfigurationsSchema, - ), - createWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, - WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationSchema, - ), - getWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, - GetWorkspaceConfigurationRequestSchema, - WorkspaceConfigurationSchema, - ), - updateWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, - WorkspaceConfigurationUpdateRequestSchema, - WorkspaceConfigurationSchema, - ), - deleteWorkspaceConfiguration: unaryDefinition( - TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, - DeleteWorkspaceConfigurationRequestSchema, - EmptySchema, - ), - listMemoryBuckets: unaryDefinition( - TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, - ListMemoryBucketsRequestSchema, - PaginatedMemoryBucketsSchema, - ), - createMemoryBucket: unaryDefinition( - TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, - MemoryBucketCreateRequestSchema, - MemoryBucketSchema, - ), - getMemoryBucket: unaryDefinition( - TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, - GetMemoryBucketRequestSchema, - MemoryBucketSchema, - ), - updateMemoryBucket: unaryDefinition( - TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, - MemoryBucketUpdateRequestSchema, - MemoryBucketSchema, - ), - deleteMemoryBucket: unaryDefinition( - TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, - DeleteMemoryBucketRequestSchema, - EmptySchema, - ), - listAttachments: unaryDefinition( - TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, - ListAttachmentsRequestSchema, - PaginatedAttachmentsSchema, - ), - createAttachment: unaryDefinition( - TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, - AttachmentCreateRequestSchema, - AttachmentSchema, - ), - deleteAttachment: unaryDefinition( - TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, - DeleteAttachmentRequestSchema, - EmptySchema, - ), -}; - -export const TeamsServiceGrpcClient = makeGenericClientConstructor( - teamsServiceGrpcDefinition, - 'agynio.api.teams.v1.TeamsService', -); - -export type TeamsServiceGrpcClientInstance = InstanceType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e63f7591f..5982db56c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: packages/platform-server: dependencies: + '@agyn/docker-runner': + specifier: workspace:* + version: link:../docker-runner '@agyn/json-schema-to-zod': specifier: workspace:* version: link:../json-schema-to-zod @@ -910,6 +913,9 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1457,6 +1463,11 @@ packages: resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} engines: {node: '>=12.10.0'} + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@grpc/proto-loader@0.8.0': resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} engines: {node: '>=6'} @@ -3817,6 +3828,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@3.3.44': + resolution: {integrity: sha512-fUpIHlsbYpxAJb285xx3vp7q5wf5mjqSn3cYwl/MhiM+DB99OdO5sOCPlO0PjO+TyOtphPs7tMVLU/RtOo/JjA==} + '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} @@ -3871,6 +3888,9 @@ packages: '@types/mustache@4.2.6': resolution: {integrity: sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==} + '@types/node@18.19.127': + resolution: {integrity: sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==} + '@types/node@20.19.19': resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} @@ -3901,6 +3921,9 @@ packages: resolution: {integrity: sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==} deprecated: This is a stub types definition. socket.io provides its own type definitions, so you do not need this installed. + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -4295,6 +4318,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4387,12 +4413,18 @@ packages: resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -4421,6 +4453,13 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buildcheck@0.0.6: + resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4527,6 +4566,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4694,6 +4736,10 @@ packages: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4928,6 +4974,14 @@ packages: dnd-core@16.0.1: resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} + docker-modem@5.0.6: + resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} + engines: {node: '>= 8.0'} + + dockerode@4.0.8: + resolution: {integrity: sha512-HdPBprWmwfHMHi12AVIFDhXIqIS+EpiOVkZaAZxgML4xf5McqEZjJZtahTPkLDxWOt84ApfWPAH9EoQwOiaAIQ==} + engines: {node: '>= 8.0'} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -5437,6 +5491,9 @@ packages: fromentries@1.3.2: resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-exists-sync@0.1.0: resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==} engines: {node: '>=0.10.0'} @@ -6617,6 +6674,9 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -6656,6 +6716,9 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nan@2.23.0: + resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7641,6 +7704,9 @@ packages: spawnd@5.0.0: resolution: {integrity: sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA==} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -7648,6 +7714,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -7820,6 +7890,13 @@ packages: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -7956,6 +8033,9 @@ packages: tw-animate-css@1.3.8: resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8012,6 +8092,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8691,6 +8774,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@0.2.3': {} '@borewit/text-codec@0.1.1': {} @@ -9159,6 +9244,13 @@ snapshots: '@grpc/proto-loader': 0.8.0 '@js-sdsl/ordered-map': 4.4.2 + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@grpc/proto-loader@0.8.0': dependencies: lodash.camelcase: 4.3.0 @@ -11893,6 +11985,17 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 24.5.2 + '@types/ssh2': 1.15.5 + + '@types/dockerode@3.3.44': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 24.5.2 + '@types/ssh2': 1.15.5 + '@types/doctrine@0.0.9': {} '@types/estree-jsx@1.0.5': @@ -11946,6 +12049,10 @@ snapshots: '@types/mustache@4.2.6': {} + '@types/node@18.19.127': + dependencies: + undici-types: 5.26.5 + '@types/node@20.19.19': dependencies: undici-types: 6.21.0 @@ -11980,6 +12087,10 @@ snapshots: - supports-color - utf-8-validate + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.127 + '@types/stack-utils@2.0.3': {} '@types/statuses@2.0.6': {} @@ -12427,6 +12538,10 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types@0.16.1: @@ -12534,12 +12649,22 @@ snapshots: baseline-browser-mapping@2.8.6: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@4.0.0: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -12583,6 +12708,14 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.6: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -12685,6 +12818,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + chownr@3.0.0: {} chromatic@13.3.4: {} @@ -12825,6 +12960,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.6 + nan: 2.23.0 + optional: true + create-require@1.1.1: {} cross-spawn@7.0.6: @@ -13038,6 +13179,27 @@ snapshots: '@react-dnd/invariant': 4.0.2 redux: 4.2.1 + docker-modem@5.0.6: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@4.0.8: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.0 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.6 + protobufjs: 7.5.4 + tar-fs: 2.1.4 + uuid: 10.0.0 + transitivePeerDependencies: + - supports-color + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -13657,6 +13819,8 @@ snapshots: fromentries@1.3.2: {} + fs-constants@1.0.0: {} + fs-exists-sync@0.1.0: {} fs.realpath@1.0.0: {} @@ -15259,6 +15423,8 @@ snapshots: dependencies: minipass: 7.1.2 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -15331,6 +15497,9 @@ snapshots: mute-stream@2.0.0: {} + nan@2.23.0: + optional: true + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -16472,10 +16641,20 @@ snapshots: transitivePeerDependencies: - supports-color + split-ca@1.0.1: {} + split2@4.2.0: {} sprintf-js@1.0.3: {} + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.23.0 + stack-trace@0.0.10: {} stack-utils@2.0.6: @@ -16647,6 +16826,21 @@ snapshots: tapable@2.2.3: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -16782,6 +16976,8 @@ snapshots: tw-animate-css@1.3.8: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -16827,6 +17023,8 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@7.12.0: {} From 914aec0a3ead80de3485143016e74358cdc16fe4 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Wed, 11 Mar 2026 07:08:44 +0000 Subject: [PATCH 27/43] fix(runner): stabilize exec teardown --- .../docker-runner/src/service/grpc/server.ts | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/docker-runner/src/service/grpc/server.ts b/packages/docker-runner/src/service/grpc/server.ts index 837b88fcf..c7916e52a 100644 --- a/packages/docker-runner/src/service/grpc/server.ts +++ b/packages/docker-runner/src/service/grpc/server.ts @@ -81,6 +81,13 @@ type ExecutionContext = { const activeExecutions = new Map(); +const shouldDebugExec = process.env.DEBUG_RUNNER_EXEC === '1'; + +const logExec = (message: string, details: Record = {}) => { + if (!shouldDebugExec) return; + console.info(`[runner exec] ${message}`, details); +}; + const runnerServicePath = (method: keyof typeof RunnerService.method): string => `/${RunnerService.typeName}/${RunnerService.method[method].name}`; @@ -861,6 +868,11 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { const closeResponses = () => { if (closed) return; + logExec('closeResponses', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + }); closed = true; responseQueue.end(); }; @@ -871,6 +883,12 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { const finish = async (target: ExecutionContext, reason: ExecExitReason, killed = false) => { if (!target || target.finished) return; + logExec('finish', { + executionId: target.executionId, + requestId: target.requestId, + reason, + killed, + }); target.finished = true; target.reason = reason; target.killed = killed; @@ -1167,26 +1185,46 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { await handleRequest(req); if (closed || ctx?.finished) break; } + logExec('handleRequests completed', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); if (!ctx || closed) { closeResponses(); return; } if (ctx.finished) { - closeResponses(); return; } return; } catch { - if (!ctx || ctx.finished || closed) { + logExec('handleRequests error', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); + if (!ctx || closed) { closeResponses(); return; } + if (ctx.finished) { + return; + } clearExecutionTimers(ctx); await finish(ctx, ExecExitReason.RUNNER_ERROR, ctx.killed); } })(); const onAbort = () => { + logExec('context aborted', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); if (!ctx || ctx.finished || closed) return; ctx.cancelRequested = true; clearExecutionTimers(ctx); @@ -1199,6 +1237,12 @@ export function createRunnerGrpcServer(opts: RunnerGrpcOptions): Http2Server { yield response; } } finally { + logExec('response loop closing', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); context.signal.removeEventListener('abort', onAbort); closeResponses(); await handleRequests.catch(() => undefined); From b1e1b4f646afc41219f1014261ce630eb37b5b9a Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Thu, 12 Mar 2026 11:41:57 +0000 Subject: [PATCH 28/43] chore(platform-server): update devspace (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(platform-server): update devspace * fix(platform-server): align devspace workflow * fix(platform-server): move devspace ports * fix(devspace): sync to /opt/app/data to avoid CRI permission issue The container runtime creates /opt/app/data/workspace as root:node 2755 which is not writable by UID 1000. Using the emptyDir mount root /opt/app/data directly resolves this since Kubernetes sets it to 2777 with fsGroup. * fix(devspace): bootstrap pnpm via corepack The current platform-server dev image does not ship with pnpm shims on PATH. Install the shims under /opt/app/data so the non-root dev container can run pnpm during startup. * fix(devspace): bootstrap buf and graph path * chore(devspace): extract startup script * fix(devspace): add transitional pnpm fallback for stale dev image Until the dev image is rebuilt from the updated Dockerfile.dev, corepack's pnpm shim isn't on PATH. This adds a conditional fallback that will be a no-op once the image includes PNPM_HOME. * fix(devspace): use corepack shims for pnpm Avoid writing to /usr/local/bin when pnpm is missing on PATH. This prepends corepack's shims directory so stale dev images can activate pnpm without permission errors. * fix(devspace): remove transitional fallbacks, build image locally for testing * fix(devspace): use tsx watch for hot reload in dev mode * docs(devspace): fix startup step 5 to match tsx watch command * docs(devspace): remove KUBECONFIG export — apply.sh merges into ~/.kube * chore(devspace): use ghcr.io/agynio/devcontainer-node:1 for dev image Extract the dev container image to its own repository (agynio/devcontainer-node) with proper versioning and CI. Remove Dockerfile.dev and the platform-server-dev-image workflow from this repo. * fix(platform-server): stabilize litellm aliases * fix(platform-server): patch devspace in place * fix(platform): stabilize exec completion - poll exec inspect to finish gRPC streams - add exec inspect helper for docker runner - align devspace patch indentation * revert: remove out-of-scope docker-runner changes * fix: stabilize exec polling and litellm * revert: remove out-of-scope docker-runner changes (again) * fix(platform-server): avoid ncps init block * fix(platform-server): restore ncps init Add LiteLLM provisioner integration coverage. Update LiteLLM test script and helpers. * test(platform-server): add litellm error paths Add mocked provisioner error path coverage and fix key store delete in integration test. * Add .devspace to gitignore --------- Co-authored-by: Vitalii Valkov --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 1 + packages/platform-server/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index a5e38d02d..55fb09aec 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -17,6 +17,7 @@ const trackedEnvKeys = [ 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', + 'DEPLOYMENT_ID', 'NODE_ENV', 'HOSTNAME', ]; diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index fcffa38e5..522352996 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -12,7 +12,7 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", - "test:litellm": "vitest run __tests__/integration/litellm.integration.test.ts", + "test:litellm": "vitest run __tests__/integration/litellm.*.test.ts", "test:watch": "vitest", "lint": "pnpm run prisma:generate && eslint .", "prepare": "prisma generate", From c39d234995248547fc38ebaf36d060d8aa5ec273 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Mon, 9 Mar 2026 09:37:07 +0000 Subject: [PATCH 29/43] feat(platform-server): add teams grpc client --- .../__tests__/config.service.fromEnv.test.ts | 1 + packages/platform-server/src/proto/grpc.ts | 409 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 packages/platform-server/src/proto/grpc.ts diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 55fb09aec..1293168d8 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -18,6 +18,7 @@ const trackedEnvKeys = [ 'DEPLOYMENT_ID', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', + 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts new file mode 100644 index 000000000..5a3d37cdb --- /dev/null +++ b/packages/platform-server/src/proto/grpc.ts @@ -0,0 +1,409 @@ +import { makeGenericClientConstructor } from '@grpc/grpc-js'; +import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; +import { toBinary, fromBinary } from '@bufbuild/protobuf'; +import type { DescMessage } from '@bufbuild/protobuf'; +import { EmptySchema } from '@bufbuild/protobuf/wkt'; +import { + CancelExecutionRequestSchema, + CancelExecutionResponseSchema, + ExecRequestSchema, + ExecResponseSchema, + FindWorkloadsByLabelsRequestSchema, + FindWorkloadsByLabelsResponseSchema, + GetWorkloadLabelsRequestSchema, + GetWorkloadLabelsResponseSchema, + InspectWorkloadRequestSchema, + InspectWorkloadResponseSchema, + ListWorkloadsByVolumeRequestSchema, + ListWorkloadsByVolumeResponseSchema, + PutArchiveRequestSchema, + PutArchiveResponseSchema, + ReadyRequestSchema, + ReadyResponseSchema, + RemoveVolumeRequestSchema, + RemoveVolumeResponseSchema, + RemoveWorkloadRequestSchema, + RemoveWorkloadResponseSchema, + StartWorkloadRequestSchema, + StartWorkloadResponseSchema, + StopWorkloadRequestSchema, + StopWorkloadResponseSchema, + StreamEventsRequestSchema, + StreamEventsResponseSchema, + StreamWorkloadLogsRequestSchema, + StreamWorkloadLogsResponseSchema, + TouchWorkloadRequestSchema, + TouchWorkloadResponseSchema, +} from './gen/agynio/api/runner/v1/runner_pb.js'; +import { + AgentCreateRequestSchema, + AgentSchema, + AgentUpdateRequestSchema, + AttachmentCreateRequestSchema, + AttachmentSchema, + DeleteAgentRequestSchema, + DeleteAttachmentRequestSchema, + DeleteMcpServerRequestSchema, + DeleteMemoryBucketRequestSchema, + DeleteToolRequestSchema, + DeleteWorkspaceConfigurationRequestSchema, + GetAgentRequestSchema, + GetMcpServerRequestSchema, + GetMemoryBucketRequestSchema, + GetToolRequestSchema, + GetWorkspaceConfigurationRequestSchema, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + McpServerCreateRequestSchema, + McpServerSchema, + McpServerUpdateRequestSchema, + MemoryBucketCreateRequestSchema, + MemoryBucketSchema, + MemoryBucketUpdateRequestSchema, + PaginatedAgentsSchema, + PaginatedAttachmentsSchema, + PaginatedMcpServersSchema, + PaginatedMemoryBucketsSchema, + PaginatedToolsSchema, + PaginatedWorkspaceConfigurationsSchema, + ToolCreateRequestSchema, + ToolSchema, + ToolUpdateRequestSchema, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationSchema, + WorkspaceConfigurationUpdateRequestSchema, +} from './gen/agynio/api/teams/v1/teams_pb.js'; + +const unaryDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: false, + responseStream: false, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +const serverStreamDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: false, + responseStream: true, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +const bidiDefinition = ( + path: string, + input: DescMessage, + output: DescMessage, +): MethodDefinition => ({ + path, + requestStream: true, + responseStream: true, + requestSerialize: (value: unknown) => Buffer.from(toBinary(input, value as never)), + responseSerialize: (value: unknown) => Buffer.from(toBinary(output, value as never)), + requestDeserialize: (buffer: Buffer) => fromBinary(input, buffer), + responseDeserialize: (buffer: Buffer) => fromBinary(output, buffer), + originalName: path.split('/').pop() ?? path, +}); + +export const RUNNER_SERVICE_READY_PATH = '/agynio.api.runner.v1.RunnerService/Ready'; +export const RUNNER_SERVICE_START_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StartWorkload'; +export const RUNNER_SERVICE_STOP_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/StopWorkload'; +export const RUNNER_SERVICE_REMOVE_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/RemoveWorkload'; +export const RUNNER_SERVICE_INSPECT_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/InspectWorkload'; +export const RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/GetWorkloadLabels'; +export const RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH = '/agynio.api.runner.v1.RunnerService/FindWorkloadsByLabels'; +export const RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/ListWorkloadsByVolume'; +export const RUNNER_SERVICE_REMOVE_VOLUME_PATH = '/agynio.api.runner.v1.RunnerService/RemoveVolume'; +export const RUNNER_SERVICE_TOUCH_WORKLOAD_PATH = '/agynio.api.runner.v1.RunnerService/TouchWorkload'; +export const RUNNER_SERVICE_PUT_ARCHIVE_PATH = '/agynio.api.runner.v1.RunnerService/PutArchive'; +export const RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH = '/agynio.api.runner.v1.RunnerService/StreamWorkloadLogs'; +export const RUNNER_SERVICE_STREAM_EVENTS_PATH = '/agynio.api.runner.v1.RunnerService/StreamEvents'; +export const RUNNER_SERVICE_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/Exec'; +export const RUNNER_SERVICE_CANCEL_EXEC_PATH = '/agynio.api.runner.v1.RunnerService/CancelExecution'; + +export const runnerServiceGrpcDefinition: ServiceDefinition = { + ready: unaryDefinition( + RUNNER_SERVICE_READY_PATH, + ReadyRequestSchema, + ReadyResponseSchema, + ), + startWorkload: unaryDefinition( + RUNNER_SERVICE_START_WORKLOAD_PATH, + StartWorkloadRequestSchema, + StartWorkloadResponseSchema, + ), + stopWorkload: unaryDefinition( + RUNNER_SERVICE_STOP_WORKLOAD_PATH, + StopWorkloadRequestSchema, + StopWorkloadResponseSchema, + ), + removeWorkload: unaryDefinition( + RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, + RemoveWorkloadRequestSchema, + RemoveWorkloadResponseSchema, + ), + inspectWorkload: unaryDefinition( + RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, + InspectWorkloadRequestSchema, + InspectWorkloadResponseSchema, + ), + getWorkloadLabels: unaryDefinition( + RUNNER_SERVICE_GET_WORKLOAD_LABELS_PATH, + GetWorkloadLabelsRequestSchema, + GetWorkloadLabelsResponseSchema, + ), + findWorkloadsByLabels: unaryDefinition( + RUNNER_SERVICE_FIND_WORKLOADS_BY_LABELS_PATH, + FindWorkloadsByLabelsRequestSchema, + FindWorkloadsByLabelsResponseSchema, + ), + listWorkloadsByVolume: unaryDefinition( + RUNNER_SERVICE_LIST_WORKLOADS_BY_VOLUME_PATH, + ListWorkloadsByVolumeRequestSchema, + ListWorkloadsByVolumeResponseSchema, + ), + removeVolume: unaryDefinition( + RUNNER_SERVICE_REMOVE_VOLUME_PATH, + RemoveVolumeRequestSchema, + RemoveVolumeResponseSchema, + ), + touchWorkload: unaryDefinition( + RUNNER_SERVICE_TOUCH_WORKLOAD_PATH, + TouchWorkloadRequestSchema, + TouchWorkloadResponseSchema, + ), + putArchive: unaryDefinition( + RUNNER_SERVICE_PUT_ARCHIVE_PATH, + PutArchiveRequestSchema, + PutArchiveResponseSchema, + ), + streamWorkloadLogs: serverStreamDefinition( + RUNNER_SERVICE_STREAM_WORKLOAD_LOGS_PATH, + StreamWorkloadLogsRequestSchema, + StreamWorkloadLogsResponseSchema, + ), + streamEvents: serverStreamDefinition( + RUNNER_SERVICE_STREAM_EVENTS_PATH, + StreamEventsRequestSchema, + StreamEventsResponseSchema, + ), + exec: bidiDefinition( + RUNNER_SERVICE_EXEC_PATH, + ExecRequestSchema, + ExecResponseSchema, + ), + cancelExecution: unaryDefinition( + RUNNER_SERVICE_CANCEL_EXEC_PATH, + CancelExecutionRequestSchema, + CancelExecutionResponseSchema, + ), +}; + +export const RunnerServiceGrpcClient = makeGenericClientConstructor( + runnerServiceGrpcDefinition, + 'agynio.api.runner.v1.RunnerService', +); + +export type RunnerServiceGrpcClientInstance = InstanceType; + +export const TEAMS_SERVICE_LIST_AGENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAgents'; +export const TEAMS_SERVICE_CREATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAgent'; +export const TEAMS_SERVICE_GET_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/GetAgent'; +export const TEAMS_SERVICE_UPDATE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/UpdateAgent'; +export const TEAMS_SERVICE_DELETE_AGENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAgent'; +export const TEAMS_SERVICE_LIST_TOOLS_PATH = '/agynio.api.teams.v1.TeamsService/ListTools'; +export const TEAMS_SERVICE_CREATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/CreateTool'; +export const TEAMS_SERVICE_GET_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/GetTool'; +export const TEAMS_SERVICE_UPDATE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/UpdateTool'; +export const TEAMS_SERVICE_DELETE_TOOL_PATH = '/agynio.api.teams.v1.TeamsService/DeleteTool'; +export const TEAMS_SERVICE_LIST_MCP_SERVERS_PATH = '/agynio.api.teams.v1.TeamsService/ListMcpServers'; +export const TEAMS_SERVICE_CREATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/CreateMcpServer'; +export const TEAMS_SERVICE_GET_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/GetMcpServer'; +export const TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMcpServer'; +export const TEAMS_SERVICE_DELETE_MCP_SERVER_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMcpServer'; +export const TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH = + '/agynio.api.teams.v1.TeamsService/ListWorkspaceConfigurations'; +export const TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/CreateWorkspaceConfiguration'; +export const TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/GetWorkspaceConfiguration'; +export const TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/UpdateWorkspaceConfiguration'; +export const TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH = + '/agynio.api.teams.v1.TeamsService/DeleteWorkspaceConfiguration'; +export const TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH = '/agynio.api.teams.v1.TeamsService/ListMemoryBuckets'; +export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/CreateMemoryBucket'; +export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; +export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; +export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; +export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; +export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; +export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; + +export const teamsServiceGrpcDefinition: ServiceDefinition = { + listAgents: unaryDefinition( + TEAMS_SERVICE_LIST_AGENTS_PATH, + ListAgentsRequestSchema, + PaginatedAgentsSchema, + ), + createAgent: unaryDefinition( + TEAMS_SERVICE_CREATE_AGENT_PATH, + AgentCreateRequestSchema, + AgentSchema, + ), + getAgent: unaryDefinition( + TEAMS_SERVICE_GET_AGENT_PATH, + GetAgentRequestSchema, + AgentSchema, + ), + updateAgent: unaryDefinition( + TEAMS_SERVICE_UPDATE_AGENT_PATH, + AgentUpdateRequestSchema, + AgentSchema, + ), + deleteAgent: unaryDefinition( + TEAMS_SERVICE_DELETE_AGENT_PATH, + DeleteAgentRequestSchema, + EmptySchema, + ), + listTools: unaryDefinition( + TEAMS_SERVICE_LIST_TOOLS_PATH, + ListToolsRequestSchema, + PaginatedToolsSchema, + ), + createTool: unaryDefinition( + TEAMS_SERVICE_CREATE_TOOL_PATH, + ToolCreateRequestSchema, + ToolSchema, + ), + getTool: unaryDefinition( + TEAMS_SERVICE_GET_TOOL_PATH, + GetToolRequestSchema, + ToolSchema, + ), + updateTool: unaryDefinition( + TEAMS_SERVICE_UPDATE_TOOL_PATH, + ToolUpdateRequestSchema, + ToolSchema, + ), + deleteTool: unaryDefinition( + TEAMS_SERVICE_DELETE_TOOL_PATH, + DeleteToolRequestSchema, + EmptySchema, + ), + listMcpServers: unaryDefinition( + TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, + ListMcpServersRequestSchema, + PaginatedMcpServersSchema, + ), + createMcpServer: unaryDefinition( + TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, + McpServerCreateRequestSchema, + McpServerSchema, + ), + getMcpServer: unaryDefinition( + TEAMS_SERVICE_GET_MCP_SERVER_PATH, + GetMcpServerRequestSchema, + McpServerSchema, + ), + updateMcpServer: unaryDefinition( + TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, + McpServerUpdateRequestSchema, + McpServerSchema, + ), + deleteMcpServer: unaryDefinition( + TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, + DeleteMcpServerRequestSchema, + EmptySchema, + ), + listWorkspaceConfigurations: unaryDefinition( + TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, + ListWorkspaceConfigurationsRequestSchema, + PaginatedWorkspaceConfigurationsSchema, + ), + createWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, + WorkspaceConfigurationCreateRequestSchema, + WorkspaceConfigurationSchema, + ), + getWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, + GetWorkspaceConfigurationRequestSchema, + WorkspaceConfigurationSchema, + ), + updateWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, + WorkspaceConfigurationUpdateRequestSchema, + WorkspaceConfigurationSchema, + ), + deleteWorkspaceConfiguration: unaryDefinition( + TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, + DeleteWorkspaceConfigurationRequestSchema, + EmptySchema, + ), + listMemoryBuckets: unaryDefinition( + TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, + ListMemoryBucketsRequestSchema, + PaginatedMemoryBucketsSchema, + ), + createMemoryBucket: unaryDefinition( + TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, + MemoryBucketCreateRequestSchema, + MemoryBucketSchema, + ), + getMemoryBucket: unaryDefinition( + TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, + GetMemoryBucketRequestSchema, + MemoryBucketSchema, + ), + updateMemoryBucket: unaryDefinition( + TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, + MemoryBucketUpdateRequestSchema, + MemoryBucketSchema, + ), + deleteMemoryBucket: unaryDefinition( + TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, + DeleteMemoryBucketRequestSchema, + EmptySchema, + ), + listAttachments: unaryDefinition( + TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, + ListAttachmentsRequestSchema, + PaginatedAttachmentsSchema, + ), + createAttachment: unaryDefinition( + TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, + AttachmentCreateRequestSchema, + AttachmentSchema, + ), + deleteAttachment: unaryDefinition( + TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, + DeleteAttachmentRequestSchema, + EmptySchema, + ), +}; + +export const TeamsServiceGrpcClient = makeGenericClientConstructor( + teamsServiceGrpcDefinition, + 'agynio.api.teams.v1.TeamsService', +); + +export type TeamsServiceGrpcClientInstance = InstanceType; From 161a2dd02e773209fa980cca5afc2baf6e09bdbc Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 12 Mar 2026 21:17:54 +0000 Subject: [PATCH 30/43] feat(platform-ui): migrate entity CRUD --- .../platform-ui/src/api/modules/teamApi.ts | 307 +++------- packages/platform-ui/src/api/types/team.ts | 199 +++--- .../src/features/entities/api/teamEntities.ts | 569 +++++++++++++----- .../entities/hooks/useTeamEntities.ts | 46 +- .../__tests__/entities-list-page.test.tsx | 38 +- 5 files changed, 620 insertions(+), 539 deletions(-) diff --git a/packages/platform-ui/src/api/modules/teamApi.ts b/packages/platform-ui/src/api/modules/teamApi.ts index 0ea69d72d..b2b39fc1e 100644 --- a/packages/platform-ui/src/api/modules/teamApi.ts +++ b/packages/platform-ui/src/api/modules/teamApi.ts @@ -1,243 +1,137 @@ import { http } from '../http'; import type { TeamAgent, - TeamAgentCreateRequest, - TeamAgentUpdateRequest, TeamAttachment, - TeamAttachmentCreateRequest, - TeamAttachmentKind, - TeamEntityType, TeamListResponse, TeamMemoryBucket, - TeamMemoryBucketCreateRequest, - TeamMemoryBucketUpdateRequest, TeamMcpServer, - TeamMcpServerCreateRequest, - TeamMcpServerUpdateRequest, TeamTool, - TeamToolCreateRequest, - TeamToolType, - TeamToolUpdateRequest, TeamWorkspaceConfiguration, - TeamWorkspaceConfigurationCreateRequest, - TeamWorkspaceConfigurationUpdateRequest, } from '../types/team'; -import { isRecord, readNumber, readString } from '@/utils/typeGuards'; const TEAM_API_PREFIX = '/apiv2/team/v1'; -const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_PAGE_SIZE = 200; export type TeamListParams = { - page?: number; - perPage?: number; - q?: string; + pageToken?: string; + pageSize?: number; }; type PageInfo = { - page: number; - perPage: number; - total: number; + nextPageToken?: string; + page?: number; + perPage?: number; + total?: number; }; -const TOOL_TYPES = new Set([ - 'manage', - 'memory', - 'shell_command', - 'send_message', - 'send_slack_message', - 'remind_me', - 'github_clone_repo', - 'call_agent', -]); - -const ATTACHMENT_KINDS = new Set([ - 'agent_tool', - 'agent_memoryBucket', - 'agent_workspaceConfiguration', - 'agent_mcpServer', - 'mcpServer_workspaceConfiguration', -]); - -const ENTITY_TYPES = new Set([ - 'agent', - 'tool', - 'mcpServer', - 'workspaceConfiguration', - 'memoryBucket', -]); - -function requireRecord(value: unknown, label: string): Record { - if (!isRecord(value)) { - throw new Error(`Unexpected ${label} response`); - } - return value; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -function readRequiredString(record: Record, key: string, label: string): string { - const value = readString(record[key]); - if (!value) { - throw new Error(`Unexpected ${label} response`); +function readString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value; } - return value; + return undefined; } -function readRequiredRecord(record: Record, key: string, label: string): Record { - const value = record[key]; - if (!isRecord(value)) { - throw new Error(`Unexpected ${label} response`); +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; } - return value; -} - -function readEnumValue(value: unknown, allowed: ReadonlySet, label: string): T { - const trimmed = readString(value); - if (!trimmed || !allowed.has(trimmed as T)) { - throw new Error(`Unexpected ${label} response`); - } - return trimmed as T; + return undefined; } function readPageInfo(record: Record): PageInfo { - const page = readNumber(record.page); - const perPage = readNumber(record.perPage); + const nextPageToken = readString(record.nextPageToken ?? record.next_page_token); + const page = readNumber(record.page ?? record.pageNumber ?? record.page_number); + const perPage = readNumber(record.perPage ?? record.per_page); const total = readNumber(record.total); - if (page === undefined || perPage === undefined || total === undefined) { - throw new Error('Unexpected list response'); - } - return { page, perPage, total }; + return { nextPageToken, page, perPage, total }; } -function parseListResponse(payload: unknown, parseItem: (item: unknown) => T): TeamListResponse { - const record = requireRecord(payload, 'list'); - const items = record.items; - if (!Array.isArray(items)) { - throw new Error('Unexpected list response'); +function getItems(record: Record, key: string): T[] { + const raw = record[key] ?? record.items; + return Array.isArray(raw) ? (raw as T[]) : []; +} + +function parseListResponse(payload: unknown, key: string): TeamListResponse { + if (!isRecord(payload)) { + return { items: [] }; } - const pageInfo = readPageInfo(record); - return { items: items.map((item) => parseItem(item)), ...pageInfo }; + const items = getItems(payload, key); + const pageInfo = readPageInfo(payload); + return { items, ...pageInfo }; } function buildListParams(params?: TeamListParams): Record { if (!params) return {}; const result: Record = {}; - if (typeof params.page === 'number') result.page = params.page; - if (typeof params.perPage === 'number') result.perPage = params.perPage; - const query = readString(params.q); - if (query) result.q = query; + if (params.pageSize !== undefined) { + result.pageSize = params.pageSize; + result.page_size = params.pageSize; + result.per_page = params.pageSize; + } + if (params.pageToken !== undefined) { + result.pageToken = params.pageToken; + result.page_token = params.pageToken; + const parsed = Number(params.pageToken); + if (Number.isFinite(parsed)) { + result.page = parsed; + } + } return result; } -function parseEntityMeta(record: Record, label: string): { id: string; createdAt: string; updatedAt: string } { - return { - id: readRequiredString(record, 'id', label), - createdAt: readRequiredString(record, 'createdAt', label), - updatedAt: readRequiredString(record, 'updatedAt', label), - }; -} - -function parseAgent(raw: unknown): TeamAgent { - const record = requireRecord(raw, 'agent'); - const meta = parseEntityMeta(record, 'agent'); - return { - ...meta, - title: readString(record.title), - description: readString(record.description), - config: readRequiredRecord(record, 'config', 'agent'), - }; -} - -function parseTool(raw: unknown): TeamTool { - const record = requireRecord(raw, 'tool'); - const meta = parseEntityMeta(record, 'tool'); - return { - ...meta, - type: readEnumValue(record.type, TOOL_TYPES, 'tool'), - name: readString(record.name), - description: readString(record.description), - config: readRequiredRecord(record, 'config', 'tool'), - }; -} - -function parseMcpServer(raw: unknown): TeamMcpServer { - const record = requireRecord(raw, 'mcp server'); - const meta = parseEntityMeta(record, 'mcp server'); - return { - ...meta, - title: readString(record.title), - description: readString(record.description), - config: readRequiredRecord(record, 'config', 'mcp server'), - }; -} - -function parseWorkspaceConfiguration(raw: unknown): TeamWorkspaceConfiguration { - const record = requireRecord(raw, 'workspace configuration'); - const meta = parseEntityMeta(record, 'workspace configuration'); - return { - ...meta, - title: readString(record.title), - description: readString(record.description), - config: readRequiredRecord(record, 'config', 'workspace configuration'), - }; -} - -function parseMemoryBucket(raw: unknown): TeamMemoryBucket { - const record = requireRecord(raw, 'memory bucket'); - const meta = parseEntityMeta(record, 'memory bucket'); - return { - ...meta, - title: readString(record.title), - description: readString(record.description), - config: readRequiredRecord(record, 'config', 'memory bucket'), - }; -} - -function parseAttachment(raw: unknown): TeamAttachment { - const record = requireRecord(raw, 'attachment'); - const meta = parseEntityMeta(record, 'attachment'); - return { - ...meta, - kind: readEnumValue(record.kind, ATTACHMENT_KINDS, 'attachment kind'), - sourceId: readRequiredString(record, 'sourceId', 'attachment'), - targetId: readRequiredString(record, 'targetId', 'attachment'), - sourceType: readEnumValue(record.sourceType, ENTITY_TYPES, 'attachment sourceType'), - targetType: readEnumValue(record.targetType, ENTITY_TYPES, 'attachment targetType'), - }; +function resolveNextPageToken(pageInfo: PageInfo, pageSize: number, currentPage: number, count: number): string | undefined { + if (pageInfo.nextPageToken) return pageInfo.nextPageToken; + if (pageInfo.page !== undefined && pageInfo.perPage !== undefined && pageInfo.total !== undefined) { + if (pageInfo.page * pageInfo.perPage >= pageInfo.total) { + return undefined; + } + return String(pageInfo.page + 1); + } + if (count < pageSize) { + return undefined; + } + return String(currentPage + 1); } async function listAllPages( fetchPage: (params: TeamListParams) => Promise>, - params?: TeamListParams, + pageSize = DEFAULT_PAGE_SIZE, ): Promise { const items: T[] = []; - let page = params?.page ?? 1; - const perPage = params?.perPage ?? DEFAULT_PAGE_SIZE; - const q = params?.q; + let pageToken: string | undefined = undefined; + let pageIndex = 1; for (let i = 0; i < 50; i += 1) { - const response = await fetchPage({ page, perPage, q }); + const response = await fetchPage({ pageSize, pageToken }); items.push(...response.items); - if (response.page * response.perPage >= response.total) break; - page = response.page + 1; + const nextToken = resolveNextPageToken(response, pageSize, pageIndex, response.items.length); + if (!nextToken) break; + pageToken = nextToken; + pageIndex += 1; } return items; } export async function listAgents(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/agents`, { params: buildListParams(params) }); - return parseListResponse(payload, parseAgent); + return parseListResponse(payload, 'agents'); } export async function listAllAgents(): Promise { return listAllPages(listAgents); } -export async function createAgent(payload: TeamAgentCreateRequest): Promise { - const response = await http.post(`${TEAM_API_PREFIX}/agents`, payload); - return parseAgent(response); +export async function createAgent(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/agents`, payload); } -export async function updateAgent(id: string, payload: TeamAgentUpdateRequest): Promise { - const response = await http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); - return parseAgent(response); +export async function updateAgent(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); } export async function deleteAgent(id: string): Promise { @@ -246,21 +140,19 @@ export async function deleteAgent(id: string): Promise { export async function listTools(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/tools`, { params: buildListParams(params) }); - return parseListResponse(payload, parseTool); + return parseListResponse(payload, 'tools'); } export async function listAllTools(): Promise { return listAllPages(listTools); } -export async function createTool(payload: TeamToolCreateRequest): Promise { - const response = await http.post(`${TEAM_API_PREFIX}/tools`, payload); - return parseTool(response); +export async function createTool(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/tools`, payload); } -export async function updateTool(id: string, payload: TeamToolUpdateRequest): Promise { - const response = await http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); - return parseTool(response); +export async function updateTool(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); } export async function deleteTool(id: string): Promise { @@ -269,21 +161,19 @@ export async function deleteTool(id: string): Promise { export async function listMcpServers(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/mcp-servers`, { params: buildListParams(params) }); - return parseListResponse(payload, parseMcpServer); + return parseListResponse(payload, 'mcpServers'); } export async function listAllMcpServers(): Promise { return listAllPages(listMcpServers); } -export async function createMcpServer(payload: TeamMcpServerCreateRequest): Promise { - const response = await http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); - return parseMcpServer(response); +export async function createMcpServer(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); } -export async function updateMcpServer(id: string, payload: TeamMcpServerUpdateRequest): Promise { - const response = await http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); - return parseMcpServer(response); +export async function updateMcpServer(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); } export async function deleteMcpServer(id: string): Promise { @@ -296,26 +186,22 @@ export async function listWorkspaceConfigurations( const payload = await http.get(`${TEAM_API_PREFIX}/workspace-configurations`, { params: buildListParams(params), }); - return parseListResponse(payload, parseWorkspaceConfiguration); + return parseListResponse(payload, 'workspaceConfigurations'); } export async function listAllWorkspaceConfigurations(): Promise { return listAllPages(listWorkspaceConfigurations); } -export async function createWorkspaceConfiguration( - payload: TeamWorkspaceConfigurationCreateRequest, -): Promise { - const response = await http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); - return parseWorkspaceConfiguration(response); +export async function createWorkspaceConfiguration(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); } export async function updateWorkspaceConfiguration( id: string, - payload: TeamWorkspaceConfigurationUpdateRequest, + payload: Record, ): Promise { - const response = await http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); - return parseWorkspaceConfiguration(response); + return http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); } export async function deleteWorkspaceConfiguration(id: string): Promise { @@ -324,21 +210,19 @@ export async function deleteWorkspaceConfiguration(id: string): Promise { export async function listMemoryBuckets(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/memory-buckets`, { params: buildListParams(params) }); - return parseListResponse(payload, parseMemoryBucket); + return parseListResponse(payload, 'memoryBuckets'); } export async function listAllMemoryBuckets(): Promise { return listAllPages(listMemoryBuckets); } -export async function createMemoryBucket(payload: TeamMemoryBucketCreateRequest): Promise { - const response = await http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); - return parseMemoryBucket(response); +export async function createMemoryBucket(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); } -export async function updateMemoryBucket(id: string, payload: TeamMemoryBucketUpdateRequest): Promise { - const response = await http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); - return parseMemoryBucket(response); +export async function updateMemoryBucket(id: string, payload: Record): Promise { + return http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); } export async function deleteMemoryBucket(id: string): Promise { @@ -347,16 +231,15 @@ export async function deleteMemoryBucket(id: string): Promise { export async function listAttachments(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/attachments`, { params: buildListParams(params) }); - return parseListResponse(payload, parseAttachment); + return parseListResponse(payload, 'attachments'); } export async function listAllAttachments(): Promise { return listAllPages(listAttachments); } -export async function createAttachment(payload: TeamAttachmentCreateRequest): Promise { - const response = await http.post(`${TEAM_API_PREFIX}/attachments`, payload); - return parseAttachment(response); +export async function createAttachment(payload: Record): Promise { + return http.post(`${TEAM_API_PREFIX}/attachments`, payload); } export async function deleteAttachment(id: string): Promise { diff --git a/packages/platform-ui/src/api/types/team.ts b/packages/platform-ui/src/api/types/team.ts index 97108c51f..de47505b4 100644 --- a/packages/platform-ui/src/api/types/team.ts +++ b/packages/platform-ui/src/api/types/team.ts @@ -1,157 +1,116 @@ -export type TeamAgentWhenBusy = 'wait' | 'injectAfterTools'; +export type TeamAgentWhenBusy = + | 'AGENT_WHEN_BUSY_UNSPECIFIED' + | 'AGENT_WHEN_BUSY_WAIT' + | 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; -export type TeamAgentProcessBuffer = 'allTogether' | 'oneByOne'; +export type TeamAgentProcessBuffer = + | 'AGENT_PROCESS_BUFFER_UNSPECIFIED' + | 'AGENT_PROCESS_BUFFER_ALL_TOGETHER' + | 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; export type TeamToolType = - | 'manage' - | 'memory' - | 'shell_command' - | 'send_message' - | 'send_slack_message' - | 'remind_me' - | 'github_clone_repo' - | 'call_agent'; - -export type TeamWorkspacePlatform = 'linux/amd64' | 'linux/arm64' | 'auto'; - -export type TeamMemoryBucketScope = 'global' | 'perThread'; - -export type TeamEntityType = 'agent' | 'tool' | 'mcpServer' | 'workspaceConfiguration' | 'memoryBucket'; + | 'TOOL_TYPE_UNSPECIFIED' + | 'TOOL_TYPE_MANAGE' + | 'TOOL_TYPE_MEMORY' + | 'TOOL_TYPE_SHELL_COMMAND' + | 'TOOL_TYPE_SEND_MESSAGE' + | 'TOOL_TYPE_SEND_SLACK_MESSAGE' + | 'TOOL_TYPE_REMIND_ME' + | 'TOOL_TYPE_GITHUB_CLONE_REPO' + | 'TOOL_TYPE_CALL_AGENT'; + +export type TeamWorkspacePlatform = + | 'WORKSPACE_PLATFORM_UNSPECIFIED' + | 'WORKSPACE_PLATFORM_LINUX_AMD64' + | 'WORKSPACE_PLATFORM_LINUX_ARM64' + | 'WORKSPACE_PLATFORM_AUTO'; + +export type TeamMemoryBucketScope = + | 'MEMORY_BUCKET_SCOPE_UNSPECIFIED' + | 'MEMORY_BUCKET_SCOPE_GLOBAL' + | 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + +export type TeamEntityType = + | 'ENTITY_TYPE_UNSPECIFIED' + | 'ENTITY_TYPE_AGENT' + | 'ENTITY_TYPE_TOOL' + | 'ENTITY_TYPE_MCP_SERVER' + | 'ENTITY_TYPE_WORKSPACE_CONFIGURATION' + | 'ENTITY_TYPE_MEMORY_BUCKET'; export type TeamAttachmentKind = - | 'agent_tool' - | 'agent_memoryBucket' - | 'agent_workspaceConfiguration' - | 'agent_mcpServer' - | 'mcpServer_workspaceConfiguration'; + | 'ATTACHMENT_KIND_UNSPECIFIED' + | 'ATTACHMENT_KIND_AGENT_TOOL' + | 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET' + | 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION' + | 'ATTACHMENT_KIND_AGENT_MCP_SERVER' + | 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION'; export interface TeamListResponse { items: T[]; - page: number; - perPage: number; - total: number; + nextPageToken?: string; + page?: number; + perPage?: number; + total?: number; } export interface TeamAgent { - id: string; - createdAt: string; - updatedAt: string; + id?: string; + createdAt?: string; + updatedAt?: string; title?: string; description?: string; - config: Record; + config?: Record | null; + meta?: { id?: string }; } export interface TeamTool { - id: string; - createdAt: string; - updatedAt: string; - type: TeamToolType; + id?: string; + createdAt?: string; + updatedAt?: string; + type?: TeamToolType | string | number; name?: string; description?: string; - config: Record; + config?: Record | null; + meta?: { id?: string }; } export interface TeamMcpServer { - id: string; - createdAt: string; - updatedAt: string; + id?: string; + createdAt?: string; + updatedAt?: string; title?: string; description?: string; - config: Record; + config?: Record | null; + meta?: { id?: string }; } export interface TeamWorkspaceConfiguration { - id: string; - createdAt: string; - updatedAt: string; + id?: string; + createdAt?: string; + updatedAt?: string; title?: string; description?: string; - config: Record; + config?: Record | null; + meta?: { id?: string }; } export interface TeamMemoryBucket { - id: string; - createdAt: string; - updatedAt: string; + id?: string; + createdAt?: string; + updatedAt?: string; title?: string; description?: string; - config: Record; + config?: Record | null; + meta?: { id?: string }; } export interface TeamAttachment { - id: string; - createdAt: string; - updatedAt: string; - kind: TeamAttachmentKind; - sourceId: string; - targetId: string; - sourceType: TeamEntityType; - targetType: TeamEntityType; -} - -export interface TeamAgentCreateRequest { - title?: string; - description?: string; - config: Record; -} - -export interface TeamAgentUpdateRequest { - title?: string; - description?: string; - config?: Record; -} - -export interface TeamToolCreateRequest { - type: TeamToolType; - name?: string; - description?: string; - config?: Record; -} - -export interface TeamToolUpdateRequest { - name?: string; - description?: string; - config?: Record; -} - -export interface TeamMcpServerCreateRequest { - title?: string; - description?: string; - config: Record; -} - -export interface TeamMcpServerUpdateRequest { - title?: string; - description?: string; - config?: Record; -} - -export interface TeamWorkspaceConfigurationCreateRequest { - title?: string; - description?: string; - config: Record; -} - -export interface TeamWorkspaceConfigurationUpdateRequest { - title?: string; - description?: string; - config?: Record; -} - -export interface TeamMemoryBucketCreateRequest { - title?: string; - description?: string; - config: Record; -} - -export interface TeamMemoryBucketUpdateRequest { - title?: string; - description?: string; - config?: Record; -} - -export interface TeamAttachmentCreateRequest { - kind: TeamAttachmentKind; - sourceId: string; - targetId: string; + id?: string; + kind?: TeamAttachmentKind | string | number; + sourceId?: string; + targetId?: string; + sourceType?: TeamEntityType | string | number; + targetType?: TeamEntityType | string | number; + meta?: { id?: string }; } diff --git a/packages/platform-ui/src/features/entities/api/teamEntities.ts b/packages/platform-ui/src/features/entities/api/teamEntities.ts index 919b55aa5..563272a4c 100644 --- a/packages/platform-ui/src/features/entities/api/teamEntities.ts +++ b/packages/platform-ui/src/features/entities/api/teamEntities.ts @@ -1,26 +1,20 @@ import type { TemplateSchema } from '@/api/types/graph'; import type { TeamAgent, - TeamAgentCreateRequest, TeamAttachment, TeamAttachmentKind, TeamMemoryBucket, - TeamMemoryBucketCreateRequest, TeamMemoryBucketScope, TeamMcpServer, - TeamMcpServerCreateRequest, TeamTool, - TeamToolCreateRequest, TeamToolType, TeamWorkspaceConfiguration, - TeamWorkspaceConfigurationCreateRequest, TeamWorkspacePlatform, } from '@/api/types/team'; import type { AgentQueueConfig, NodeConfig } from '@/components/nodeProperties/types'; import { readEnvList, readQueueConfig, readSummarizationConfig } from '@/components/nodeProperties/utils'; import { buildGraphNodeFromTemplate } from '@/features/graph/mappers'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; -import { isRecord, readNumber, readString } from '@/utils/typeGuards'; import type { GraphEdgeFilter, GraphEntityKind, @@ -34,33 +28,34 @@ export const EXCLUDED_WORKSPACE_TEMPLATES = new Set(['memory', 'memoryConnector' export const INCLUDED_MEMORY_TEMPLATES = new Set(['memory']); export const TEAM_ATTACHMENT_KIND = { - agentTool: 'agent_tool', - agentMemoryBucket: 'agent_memoryBucket', - agentWorkspaceConfiguration: 'agent_workspaceConfiguration', - agentMcpServer: 'agent_mcpServer', - mcpServerWorkspaceConfiguration: 'mcpServer_workspaceConfiguration', + agentTool: 'ATTACHMENT_KIND_AGENT_TOOL', + agentMemoryBucket: 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET', + agentWorkspaceConfiguration: 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION', + agentMcpServer: 'ATTACHMENT_KIND_AGENT_MCP_SERVER', + mcpServerWorkspaceConfiguration: 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION', } as const satisfies Record; export const TEAM_TOOL_TYPE = { - manage: 'manage', - memory: 'memory', - shellCommand: 'shell_command', - sendMessage: 'send_message', - sendSlackMessage: 'send_slack_message', - remindMe: 'remind_me', - githubCloneRepo: 'github_clone_repo', - callAgent: 'call_agent', + manage: 'TOOL_TYPE_MANAGE', + memory: 'TOOL_TYPE_MEMORY', + shellCommand: 'TOOL_TYPE_SHELL_COMMAND', + sendMessage: 'TOOL_TYPE_SEND_MESSAGE', + sendSlackMessage: 'TOOL_TYPE_SEND_SLACK_MESSAGE', + remindMe: 'TOOL_TYPE_REMIND_ME', + githubCloneRepo: 'TOOL_TYPE_GITHUB_CLONE_REPO', + callAgent: 'TOOL_TYPE_CALL_AGENT', } as const satisfies Record; const TOOL_TYPE_TO_TEMPLATE: Record = { - manage: 'manageTool', - memory: 'memoryTool', - shell_command: 'shellTool', - send_message: 'sendMessageTool', - send_slack_message: 'sendSlackMessageTool', - remind_me: 'remindMeTool', - github_clone_repo: 'githubCloneRepoTool', - call_agent: 'callAgentTool', + TOOL_TYPE_UNSPECIFIED: 'tool', + TOOL_TYPE_MANAGE: 'manageTool', + TOOL_TYPE_MEMORY: 'memoryTool', + TOOL_TYPE_SHELL_COMMAND: 'shellTool', + TOOL_TYPE_SEND_MESSAGE: 'sendMessageTool', + TOOL_TYPE_SEND_SLACK_MESSAGE: 'sendSlackMessageTool', + TOOL_TYPE_REMIND_ME: 'remindMeTool', + TOOL_TYPE_GITHUB_CLONE_REPO: 'githubCloneRepoTool', + TOOL_TYPE_CALL_AGENT: 'callAgentTool', }; const TEMPLATE_TO_TOOL_TYPE: Record = Object.entries(TOOL_TYPE_TO_TEMPLATE).reduce( @@ -81,11 +76,12 @@ const ENTITY_KIND_TO_NODE_KIND: Record }; const ATTACHMENT_KIND_HANDLES: Record = { - agent_tool: { sourceHandle: 'tools', targetHandle: '$self' }, - agent_memoryBucket: { sourceHandle: 'memory', targetHandle: '$self' }, - agent_workspaceConfiguration: { sourceHandle: 'workspace', targetHandle: '$self' }, - agent_mcpServer: { sourceHandle: 'mcp', targetHandle: '$self' }, - mcpServer_workspaceConfiguration: { sourceHandle: 'workspace', targetHandle: '$self' }, + ATTACHMENT_KIND_UNSPECIFIED: { sourceHandle: '$self', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_TOOL: { sourceHandle: 'tools', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_MEMORY_BUCKET: { sourceHandle: 'memory', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, + ATTACHMENT_KIND_AGENT_MCP_SERVER: { sourceHandle: 'mcp', targetHandle: '$self' }, + ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, }; export type TeamAttachmentInput = { @@ -94,6 +90,17 @@ export type TeamAttachmentInput = { targetId: string; }; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + return undefined; +} + function readOptionalString(value: unknown): string | undefined { if (typeof value === 'string') { return value; @@ -101,6 +108,15 @@ function readOptionalString(value: unknown): string | undefined { return undefined; } +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + function readBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') return value; if (typeof value === 'string') { @@ -110,51 +126,256 @@ function readBoolean(value: unknown): boolean | undefined { return undefined; } +function readField(record: Record, keys: string[], reader: (value: unknown) => T | undefined): T | undefined { + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + const value = reader(record[key]); + if (value !== undefined) return value; + } + return undefined; +} + +function normalizeEnumName(value: unknown): string { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'string') { + return value.trim().toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); + } + return ''; +} + +function normalizeTeamToolType(value: unknown): TeamToolType | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return TEAM_TOOL_TYPE.manage; + case 2: + return TEAM_TOOL_TYPE.memory; + case 3: + return TEAM_TOOL_TYPE.shellCommand; + case 4: + return TEAM_TOOL_TYPE.sendMessage; + case 5: + return TEAM_TOOL_TYPE.sendSlackMessage; + case 6: + return TEAM_TOOL_TYPE.remindMe; + case 7: + return TEAM_TOOL_TYPE.githubCloneRepo; + case 8: + return TEAM_TOOL_TYPE.callAgent; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('TOOL_TYPE_')) { + return normalized as TeamToolType; + } + switch (normalized) { + case 'MANAGE': + return TEAM_TOOL_TYPE.manage; + case 'MEMORY': + return TEAM_TOOL_TYPE.memory; + case 'SHELL_COMMAND': + case 'SHELL': + return TEAM_TOOL_TYPE.shellCommand; + case 'SEND_MESSAGE': + return TEAM_TOOL_TYPE.sendMessage; + case 'SEND_SLACK_MESSAGE': + return TEAM_TOOL_TYPE.sendSlackMessage; + case 'REMIND_ME': + return TEAM_TOOL_TYPE.remindMe; + case 'GITHUB_CLONE_REPO': + return TEAM_TOOL_TYPE.githubCloneRepo; + case 'CALL_AGENT': + return TEAM_TOOL_TYPE.callAgent; + default: + return undefined; + } +} + +function normalizeAttachmentKind(value: unknown): TeamAttachmentKind | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return TEAM_ATTACHMENT_KIND.agentTool; + case 2: + return TEAM_ATTACHMENT_KIND.agentMemoryBucket; + case 3: + return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; + case 4: + return TEAM_ATTACHMENT_KIND.agentMcpServer; + case 5: + return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('ATTACHMENT_KIND_')) { + if (normalized === 'ATTACHMENT_KIND_UNSPECIFIED') return undefined; + return normalized as TeamAttachmentKind; + } + switch (normalized) { + case 'AGENT_TOOL': + return TEAM_ATTACHMENT_KIND.agentTool; + case 'AGENT_MEMORY_BUCKET': + return TEAM_ATTACHMENT_KIND.agentMemoryBucket; + case 'AGENT_WORKSPACE_CONFIGURATION': + return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; + case 'AGENT_MCP_SERVER': + return TEAM_ATTACHMENT_KIND.agentMcpServer; + case 'MCP_SERVER_WORKSPACE_CONFIGURATION': + return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; + default: + return undefined; + } +} + +function normalizeWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return 'WORKSPACE_PLATFORM_LINUX_AMD64'; + case 2: + return 'WORKSPACE_PLATFORM_LINUX_ARM64'; + case 3: + return 'WORKSPACE_PLATFORM_AUTO'; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('WORKSPACE_PLATFORM_')) { + return normalized as TeamWorkspacePlatform; + } + switch (normalized) { + case 'LINUX_AMD64': + case 'LINUX_AMD_64': + case 'LINUX/AMD64': + return 'WORKSPACE_PLATFORM_LINUX_AMD64'; + case 'LINUX_ARM64': + case 'LINUX_ARM_64': + case 'LINUX/ARM64': + return 'WORKSPACE_PLATFORM_LINUX_ARM64'; + case 'AUTO': + return 'WORKSPACE_PLATFORM_AUTO'; + default: + return undefined; + } +} + +function normalizeMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { + if (typeof value === 'number') { + switch (value) { + case 1: + return 'MEMORY_BUCKET_SCOPE_GLOBAL'; + case 2: + return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + default: + return undefined; + } + } + const normalized = normalizeEnumName(value); + if (normalized.startsWith('MEMORY_BUCKET_SCOPE_')) { + return normalized as TeamMemoryBucketScope; + } + switch (normalized) { + case 'GLOBAL': + return 'MEMORY_BUCKET_SCOPE_GLOBAL'; + case 'PER_THREAD': + case 'PERTHREAD': + return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + default: + return undefined; + } +} + type AgentQueueWhenBusy = NonNullable; type AgentQueueProcessBuffer = NonNullable; -const QUEUE_WHEN_BUSY_VALUES: AgentQueueWhenBusy[] = ['wait', 'injectAfterTools']; -const QUEUE_PROCESS_BUFFER_VALUES: AgentQueueProcessBuffer[] = ['allTogether', 'oneByOne']; -const WORKSPACE_PLATFORM_VALUES: TeamWorkspacePlatform[] = ['linux/amd64', 'linux/arm64', 'auto']; -const MEMORY_SCOPE_VALUES: TeamMemoryBucketScope[] = ['global', 'perThread']; -function readEnumValue(value: unknown, allowed: readonly T[], label: string): T | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value !== 'string') { - throw new Error(`Unexpected ${label} value`); +function parseWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { + if (typeof value === 'number') { + if (value === 1) return 'wait'; + if (value === 2) return 'injectAfterTools'; + } + const normalized = normalizeEnumName(value); + if (normalized.includes('WAIT')) return 'wait'; + if (normalized.includes('INJECT_AFTER_TOOLS')) return 'injectAfterTools'; + return undefined; +} + +function parseProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { + if (typeof value === 'number') { + if (value === 1) return 'allTogether'; + if (value === 2) return 'oneByOne'; + } + const normalized = normalizeEnumName(value); + if (normalized.includes('ALL_TOGETHER')) return 'allTogether'; + if (normalized.includes('ONE_BY_ONE')) return 'oneByOne'; + return undefined; +} + +function mapWorkspacePlatformToUi(value: unknown): string | undefined { + const normalized = normalizeWorkspacePlatform(value); + switch (normalized) { + case 'WORKSPACE_PLATFORM_LINUX_AMD64': + return 'linux/amd64'; + case 'WORKSPACE_PLATFORM_LINUX_ARM64': + return 'linux/arm64'; + case 'WORKSPACE_PLATFORM_AUTO': + return 'auto'; + default: + return undefined; } - const trimmed = value.trim(); - if (!trimmed) return undefined; - if (!allowed.includes(trimmed as T)) { - throw new Error(`Unexpected ${label} value`); +} + +function mapWorkspacePlatformToTeam(value: unknown): TeamWorkspacePlatform | undefined { + if (typeof value === 'string') { + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'linux/amd64') return 'WORKSPACE_PLATFORM_LINUX_AMD64'; + if (trimmed === 'linux/arm64') return 'WORKSPACE_PLATFORM_LINUX_ARM64'; + if (trimmed === 'auto') return 'WORKSPACE_PLATFORM_AUTO'; } - return trimmed as T; + return normalizeWorkspacePlatform(value); } -function readQueueWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { - return readEnumValue(value, QUEUE_WHEN_BUSY_VALUES, 'agent whenBusy'); +function mapMemoryScopeToUi(value: unknown): string | undefined { + const normalized = normalizeMemoryScope(value); + if (normalized === 'MEMORY_BUCKET_SCOPE_GLOBAL') return 'global'; + if (normalized === 'MEMORY_BUCKET_SCOPE_PER_THREAD') return 'perThread'; + return undefined; } -function readQueueProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { - return readEnumValue(value, QUEUE_PROCESS_BUFFER_VALUES, 'agent processBuffer'); +function mapMemoryScopeToTeam(value: unknown): TeamMemoryBucketScope | undefined { + if (typeof value === 'string') { + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'global') return 'MEMORY_BUCKET_SCOPE_GLOBAL'; + if (trimmed === 'perthread' || trimmed === 'per_thread') return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; + } + return normalizeMemoryScope(value); } -function readWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { - return readEnumValue(value, WORKSPACE_PLATFORM_VALUES, 'workspace platform'); +function mapWhenBusyToTeam(value: unknown): string | undefined { + if (value === 'wait') return 'AGENT_WHEN_BUSY_WAIT'; + if (value === 'injectAfterTools') return 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; + return undefined; } -function readMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { - return readEnumValue(value, MEMORY_SCOPE_VALUES, 'memory bucket scope'); +function mapProcessBufferToTeam(value: unknown): string | undefined { + if (value === 'allTogether') return 'AGENT_PROCESS_BUFFER_ALL_TOGETHER'; + if (value === 'oneByOne') return 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; + return undefined; } function mapAgentConfigFromTeam(raw: Record): Record { const config: Record = {}; - const model = readString(raw.model); + const model = readField(raw, ['model'], readString); if (model) config.model = model; - const systemPrompt = readOptionalString(raw.systemPrompt); + const systemPrompt = readField(raw, ['systemPrompt', 'system_prompt'], readString); if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; - const debounceMs = readNumber(raw.debounceMs); - const whenBusy = readQueueWhenBusy(raw.whenBusy); - const processBuffer = readQueueProcessBuffer(raw.processBuffer); + const debounceMs = readField(raw, ['debounceMs', 'debounce_ms'], readNumber); + const whenBusy = parseWhenBusy(readField(raw, ['whenBusy', 'when_busy'], (value) => value)); + const processBuffer = parseProcessBuffer(readField(raw, ['processBuffer', 'process_buffer'], (value) => value)); if (debounceMs !== undefined || whenBusy || processBuffer) { const queue: Record = {}; if (debounceMs !== undefined) queue.debounceMs = debounceMs; @@ -162,27 +383,43 @@ function mapAgentConfigFromTeam(raw: Record): Record = {}; if (summarizationKeepTokens !== undefined) summarization.keepTokens = summarizationKeepTokens; if (summarizationMaxTokens !== undefined) summarization.maxTokens = summarizationMaxTokens; config.summarization = summarization; } - const restrictOutput = readBoolean(raw.restrictOutput); + const restrictOutput = readField(raw, ['restrictOutput', 'restrict_output'], readBoolean); if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; - const restrictionMessage = readOptionalString(raw.restrictionMessage); + const restrictionMessage = readField(raw, ['restrictionMessage', 'restriction_message'], readString); if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; - const restrictionMaxInjections = readNumber(raw.restrictionMaxInjections); + const restrictionMaxInjections = readField( + raw, + ['restrictionMaxInjections', 'restriction_max_injections'], + readNumber, + ); if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; - const name = readString(raw.name); + const name = readField(raw, ['name'], readString); if (name) config.name = name; - const role = readString(raw.role); + const role = readField(raw, ['role'], readString); if (role) config.role = role; return config; } @@ -198,27 +435,27 @@ function mapToolConfigFromTeam(raw: Record, tool: TeamTool): Re function mapMcpConfigFromTeam(raw: Record): Record { const config: Record = {}; - const namespace = readString(raw.namespace); + const namespace = readField(raw, ['namespace'], readString); if (namespace) config.namespace = namespace; - const command = readString(raw.command); + const command = readField(raw, ['command'], readString); if (command) config.command = command; - const workdir = readString(raw.workdir); + const workdir = readField(raw, ['workdir'], readString); if (workdir) config.workdir = workdir; - const env = Array.isArray(raw.env) ? raw.env : undefined; + const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); if (env) config.env = env; - const requestTimeoutMs = readNumber(raw.requestTimeoutMs); + const requestTimeoutMs = readField(raw, ['requestTimeoutMs', 'request_timeout_ms'], readNumber); if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; - const startupTimeoutMs = readNumber(raw.startupTimeoutMs); + const startupTimeoutMs = readField(raw, ['startupTimeoutMs', 'startup_timeout_ms'], readNumber); if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; - const heartbeatIntervalMs = readNumber(raw.heartbeatIntervalMs); + const heartbeatIntervalMs = readField(raw, ['heartbeatIntervalMs', 'heartbeat_interval_ms'], readNumber); if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; - const staleTimeoutMs = readNumber(raw.staleTimeoutMs); + const staleTimeoutMs = readField(raw, ['staleTimeoutMs', 'stale_timeout_ms'], readNumber); if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; - const restart = isRecord(raw.restart) ? raw.restart : undefined; + const restart = readField(raw, ['restart'], (value) => (isRecord(value) ? value : undefined)); if (restart) { const restartConfig: Record = {}; - const maxAttempts = readNumber(restart.maxAttempts); - const backoffMs = readNumber(restart.backoffMs); + const maxAttempts = readField(restart, ['maxAttempts', 'max_attempts'], readNumber); + const backoffMs = readField(restart, ['backoffMs', 'backoff_ms'], readNumber); if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; config.restart = restartConfig; @@ -228,32 +465,31 @@ function mapMcpConfigFromTeam(raw: Record): Record): Record { const config: Record = {}; - const image = readString(raw.image); + const image = readField(raw, ['image'], readString); if (image) config.image = image; - const env = Array.isArray(raw.env) ? raw.env : undefined; + const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); if (env) config.env = env; - const initialScript = readOptionalString(raw.initialScript); + const initialScript = readField(raw, ['initialScript', 'initial_script'], readOptionalString); if (initialScript !== undefined) config.initialScript = initialScript; - if (typeof raw.cpuLimit === 'string' || typeof raw.cpuLimit === 'number') { - config.cpu_limit = raw.cpuLimit; - } - if (typeof raw.memoryLimit === 'string' || typeof raw.memoryLimit === 'number') { - config.memory_limit = raw.memoryLimit; - } - const platform = readWorkspacePlatform(raw.platform); - if (platform) config.platform = platform; - const enableDinD = readBoolean(raw.enableDinD); + const cpuLimit = readField(raw, ['cpu_limit', 'cpuLimit'], (value) => value as unknown); + if (cpuLimit !== undefined) config.cpu_limit = cpuLimit; + const memoryLimit = readField(raw, ['memory_limit', 'memoryLimit'], (value) => value as unknown); + if (memoryLimit !== undefined) config.memory_limit = memoryLimit; + const platform = readField(raw, ['platform'], (value) => value as unknown); + const platformValue = mapWorkspacePlatformToUi(platform); + if (platformValue) config.platform = platformValue; + const enableDinD = readField(raw, ['enableDinD', 'enable_dind', 'enableDind'], readBoolean); if (enableDinD !== undefined) config.enableDinD = enableDinD; - const ttlSeconds = readNumber(raw.ttlSeconds); + const ttlSeconds = readField(raw, ['ttlSeconds', 'ttl_seconds'], readNumber); if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; - const nix = isRecord(raw.nix) ? raw.nix : undefined; + const nix = readField(raw, ['nix'], (value) => (isRecord(value) ? value : undefined)); if (nix) config.nix = nix; - const volumes = isRecord(raw.volumes) ? raw.volumes : undefined; + const volumes = readField(raw, ['volumes'], (value) => (isRecord(value) ? value : undefined)); if (volumes) { const volumeConfig: Record = {}; - const enabled = readBoolean(volumes.enabled); + const enabled = readField(volumes, ['enabled'], readBoolean); if (enabled !== undefined) volumeConfig.enabled = enabled; - const mountPath = readOptionalString(volumes.mountPath); + const mountPath = readField(volumes, ['mountPath', 'mount_path'], readOptionalString); if (mountPath !== undefined) volumeConfig.mountPath = mountPath; config.volumes = volumeConfig; } @@ -262,9 +498,10 @@ function mapWorkspaceConfigFromTeam(raw: Record): Record): Record { const config: Record = {}; - const scopeValue = readMemoryScope(raw.scope); + const scope = readField(raw, ['scope'], (value) => value as unknown); + const scopeValue = mapMemoryScopeToUi(scope); if (scopeValue) config.scope = scopeValue; - const collectionPrefix = readOptionalString(raw.collectionPrefix); + const collectionPrefix = readField(raw, ['collectionPrefix', 'collection_prefix'], readOptionalString); if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; return config; } @@ -412,10 +649,11 @@ export function mapTeamEntities( for (const agent of sources.agents ?? []) { if (!agent) continue; - const id = agent.id; + const id = readString(agent.id ?? agent.meta?.id); + if (!id) continue; const template = selectTemplate(templates, 'agent', { preferredNames: ['agent'] }); const templateName = template?.name ?? 'agent'; - const config = mapAgentConfigFromTeam(agent.config); + const config = mapAgentConfigFromTeam(isRecord(agent.config) ? agent.config : {}); const title = resolveEntityTitle(readString(agent.title) ?? template?.title ?? templateName) || templateName; addSummary({ id, @@ -434,12 +672,13 @@ export function mapTeamEntities( for (const tool of sources.tools ?? []) { if (!tool) continue; - const id = tool.id; - const toolType = tool.type; - const templateName = TOOL_TYPE_TO_TEMPLATE[toolType] ?? 'tool'; + const id = readString(tool.id ?? tool.meta?.id); + if (!id) continue; + const toolType = normalizeTeamToolType(tool.type); + const templateName = (toolType && TOOL_TYPE_TO_TEMPLATE[toolType]) || 'tool'; const template = templates.find((entry) => entry.name === templateName) ?? selectTemplate(templates, 'tool'); - const config = mapToolConfigFromTeam(tool.config, tool); + const config = mapToolConfigFromTeam(isRecord(tool.config) ? tool.config : {}, tool); const titleCandidate = readString(tool.description) ?? readString(tool.name) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -461,10 +700,11 @@ export function mapTeamEntities( for (const mcpServer of sources.mcpServers ?? []) { if (!mcpServer) continue; - const id = mcpServer.id; + const id = readString(mcpServer.id ?? mcpServer.meta?.id); + if (!id) continue; const template = selectTemplate(templates, 'mcp', { preferredNames: ['mcpServer', 'mcp'] }); const templateName = template?.name ?? 'mcp'; - const config = mapMcpConfigFromTeam(mcpServer.config); + const config = mapMcpConfigFromTeam(isRecord(mcpServer.config) ? mcpServer.config : {}); const titleCandidate = readString(mcpServer.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -484,13 +724,14 @@ export function mapTeamEntities( for (const workspace of sources.workspaceConfigurations ?? []) { if (!workspace) continue; - const id = workspace.id; + const id = readString(workspace.id ?? workspace.meta?.id); + if (!id) continue; const template = selectTemplate(templates, 'workspace', { preferredNames: ['workspace'], excludeNames: EXCLUDED_WORKSPACE_TEMPLATES, }); const templateName = template?.name ?? 'workspace'; - const config = mapWorkspaceConfigFromTeam(workspace.config); + const config = mapWorkspaceConfigFromTeam(isRecord(workspace.config) ? workspace.config : {}); const titleCandidate = readString(workspace.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -510,13 +751,14 @@ export function mapTeamEntities( for (const memory of sources.memoryBuckets ?? []) { if (!memory) continue; - const id = memory.id; + const id = readString(memory.id ?? memory.meta?.id); + if (!id) continue; const template = selectTemplate(templates, 'memory', { preferredNames: ['memory'], includeNames: INCLUDED_MEMORY_TEMPLATES, }); const templateName = template?.name ?? 'memory'; - const config = mapMemoryBucketConfigFromTeam(memory.config); + const config = mapMemoryBucketConfigFromTeam(isRecord(memory.config) ? memory.config : {}); const titleCandidate = readString(memory.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -563,9 +805,12 @@ export function mapTeamAttachmentsToEdges(attachments: TeamAttachment[] | undefi const edges: GraphPersistedEdge[] = []; for (const attachment of attachments) { if (!attachment) continue; - const handles = ATTACHMENT_KIND_HANDLES[attachment.kind]; - const sourceId = attachment.sourceId; - const targetId = attachment.targetId; + const kind = normalizeAttachmentKind(attachment.kind); + if (!kind) continue; + const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); + const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); + if (!sourceId || !targetId) continue; + const handles = ATTACHMENT_KIND_HANDLES[kind]; const id = buildEdgeId(sourceId, handles.sourceHandle, targetId, handles.targetHandle); edges.push({ id, @@ -612,6 +857,10 @@ export function mapTeamEntitiesToGraphNodes( }); } +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + function isEnvEntryRecord(value: Record): boolean { return typeof value.name === 'string' && Object.prototype.hasOwnProperty.call(value, 'value'); } @@ -630,13 +879,13 @@ function sanitizeEnvEntry(entry: Record): Record { - if (isRecord(item) && isEnvEntryRecord(item)) { + if (isPlainRecord(item) && isEnvEntryRecord(item)) { return sanitizeEnvEntry(item); } return sanitizeConfigValue(item); }); } - if (isRecord(value)) { + if (isPlainRecord(value)) { if (isEnvEntryRecord(value)) { return sanitizeEnvEntry(value); } @@ -650,7 +899,7 @@ function sanitizeConfigValue(value: unknown): unknown { } export function sanitizeConfigForPersistence(_templateName: string, config: Record | undefined): Record { - const base = isRecord(config) ? config : {}; + const base = isPlainRecord(config) ? config : {}; const sanitized: Record = {}; for (const [key, value] of Object.entries(base)) { if (key === 'title' || key === 'template' || key === 'kind') { @@ -697,8 +946,13 @@ export function diffTeamAttachments( const normalizedCurrent: Array<{ key: string; attachment: TeamAttachment }> = []; for (const attachment of current ?? []) { - const key = `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; - normalizedCurrent.push({ key, attachment }); + const kind = normalizeAttachmentKind(attachment.kind); + if (!kind) continue; + const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); + const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); + if (!sourceId || !targetId) continue; + const key = `${kind}:${sourceId}:${targetId}`; + normalizedCurrent.push({ key, attachment: { ...attachment, kind } }); } const currentKeys = new Set(normalizedCurrent.map((entry) => entry.key)); @@ -715,11 +969,11 @@ function mapEnvListForTeam(env: unknown): Array<{ name: string; value: string }> .filter((item) => item.name.length > 0); } -export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): TeamAgentCreateRequest { +export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { const configRecord = input.config as Record; const queue = readQueueConfig(configRecord as NodeConfig); const summarization = readSummarizationConfig(configRecord as NodeConfig); - const payload: TeamAgentCreateRequest = { + const payload: Record = { title: input.title, description: existing?.description ?? '', config: {}, @@ -728,20 +982,20 @@ export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: Grap const model = readOptionalString(configRecord.model); if (model !== undefined) config.model = model; const systemPrompt = readOptionalString(configRecord.systemPrompt); - if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; - if (queue.debounceMs !== undefined) config.debounceMs = queue.debounceMs; - if (queue.whenBusy) config.whenBusy = queue.whenBusy; - if (queue.processBuffer) config.processBuffer = queue.processBuffer; + if (systemPrompt !== undefined) config.system_prompt = systemPrompt; + if (queue.debounceMs !== undefined) config.debounce_ms = queue.debounceMs; + if (queue.whenBusy) config.when_busy = mapWhenBusyToTeam(queue.whenBusy); + if (queue.processBuffer) config.process_buffer = mapProcessBufferToTeam(queue.processBuffer); const sendFinal = readBoolean(configRecord.sendFinalResponseToThread); - if (sendFinal !== undefined) config.sendFinalResponseToThread = sendFinal; - if (summarization.keepTokens !== undefined) config.summarizationKeepTokens = summarization.keepTokens; - if (summarization.maxTokens !== undefined) config.summarizationMaxTokens = summarization.maxTokens; + if (sendFinal !== undefined) config.send_final_response_to_thread = sendFinal; + if (summarization.keepTokens !== undefined) config.summarization_keep_tokens = summarization.keepTokens; + if (summarization.maxTokens !== undefined) config.summarization_max_tokens = summarization.maxTokens; const restrictOutput = readBoolean(configRecord.restrictOutput); - if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; + if (restrictOutput !== undefined) config.restrict_output = restrictOutput; const restrictionMessage = readOptionalString(configRecord.restrictionMessage); - if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; + if (restrictionMessage !== undefined) config.restriction_message = restrictionMessage; const restrictionMaxInjections = readNumber(configRecord.restrictionMaxInjections); - if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; + if (restrictionMaxInjections !== undefined) config.restriction_max_injections = restrictionMaxInjections; const name = readOptionalString(configRecord.name); if (name !== undefined) config.name = name; const role = readOptionalString(configRecord.role); @@ -750,7 +1004,7 @@ export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: Grap return payload; } -export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): TeamToolCreateRequest { +export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { const configRecord = input.config as Record; const toolType = TEMPLATE_TO_TOOL_TYPE[input.template] ?? existing?.toolType ?? TEAM_TOOL_TYPE.manage; const toolName = readOptionalString(configRecord.name) ?? existing?.toolName ?? input.title; @@ -762,12 +1016,9 @@ export function buildToolRequest(input: GraphEntityUpsertInput, existing?: Graph }; } -export function buildMcpServerRequest( - input: GraphEntityUpsertInput, - existing?: GraphEntitySummary, -): TeamMcpServerCreateRequest { +export function buildMcpServerRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { const configRecord = input.config as Record; - const payload: TeamMcpServerCreateRequest = { + const payload: Record = { title: input.title, description: existing?.description ?? '', config: {}, @@ -782,32 +1033,29 @@ export function buildMcpServerRequest( const env = mapEnvListForTeam(configRecord.env); if (env.length > 0) config.env = env; const requestTimeoutMs = readNumber(configRecord.requestTimeoutMs); - if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; + if (requestTimeoutMs !== undefined) config.request_timeout_ms = requestTimeoutMs; const startupTimeoutMs = readNumber(configRecord.startupTimeoutMs); - if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; + if (startupTimeoutMs !== undefined) config.startup_timeout_ms = startupTimeoutMs; const heartbeatIntervalMs = readNumber(configRecord.heartbeatIntervalMs); - if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; + if (heartbeatIntervalMs !== undefined) config.heartbeat_interval_ms = heartbeatIntervalMs; const staleTimeoutMs = readNumber(configRecord.staleTimeoutMs); - if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; + if (staleTimeoutMs !== undefined) config.stale_timeout_ms = staleTimeoutMs; const restart = isRecord(configRecord.restart) ? configRecord.restart : {}; const maxAttempts = readNumber(restart.maxAttempts); const backoffMs = readNumber(restart.backoffMs); if (maxAttempts !== undefined || backoffMs !== undefined) { const restartConfig: Record = {}; - if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; - if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; + if (maxAttempts !== undefined) restartConfig.max_attempts = maxAttempts; + if (backoffMs !== undefined) restartConfig.backoff_ms = backoffMs; config.restart = restartConfig; } payload.config = config; return payload; } -export function buildWorkspaceRequest( - input: GraphEntityUpsertInput, - existing?: GraphEntitySummary, -): TeamWorkspaceConfigurationCreateRequest { +export function buildWorkspaceRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { const configRecord = input.config as Record; - const payload: TeamWorkspaceConfigurationCreateRequest = { + const payload: Record = { title: input.title, description: existing?.description ?? '', config: {}, @@ -818,43 +1066,40 @@ export function buildWorkspaceRequest( const env = mapEnvListForTeam(configRecord.env); if (env.length > 0) config.env = env; const initialScript = readOptionalString(configRecord.initialScript); - if (initialScript !== undefined) config.initialScript = initialScript; - if (configRecord.cpu_limit !== undefined) config.cpuLimit = configRecord.cpu_limit; - if (configRecord.memory_limit !== undefined) config.memoryLimit = configRecord.memory_limit; - const platform = readWorkspacePlatform(configRecord.platform); + if (initialScript !== undefined) config.initial_script = initialScript; + if (configRecord.cpu_limit !== undefined) config.cpu_limit = configRecord.cpu_limit; + if (configRecord.memory_limit !== undefined) config.memory_limit = configRecord.memory_limit; + const platform = mapWorkspacePlatformToTeam(configRecord.platform); if (platform) config.platform = platform; - const enableDinD = readBoolean(configRecord.enableDinD); - if (enableDinD !== undefined) config.enableDinD = enableDinD; + const enableDinD = readBoolean(configRecord.enableDinD ?? configRecord.enable_dind ?? configRecord.enableDind); + if (enableDinD !== undefined) config.enable_dind = enableDinD; const ttlSeconds = readNumber(configRecord.ttlSeconds); - if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; + if (ttlSeconds !== undefined) config.ttl_seconds = ttlSeconds; if (isRecord(configRecord.nix)) config.nix = configRecord.nix; if (isRecord(configRecord.volumes)) { const volumeConfig: Record = {}; const enabled = readBoolean(configRecord.volumes.enabled); if (enabled !== undefined) volumeConfig.enabled = enabled; - const mountPath = readOptionalString(configRecord.volumes.mountPath); - if (mountPath !== undefined) volumeConfig.mountPath = mountPath; + const mountPath = readOptionalString(configRecord.volumes.mountPath ?? configRecord.volumes.mount_path); + if (mountPath !== undefined) volumeConfig.mount_path = mountPath; config.volumes = volumeConfig; } payload.config = config; return payload; } -export function buildMemoryBucketRequest( - input: GraphEntityUpsertInput, - existing?: GraphEntitySummary, -): TeamMemoryBucketCreateRequest { +export function buildMemoryBucketRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { const configRecord = input.config as Record; - const payload: TeamMemoryBucketCreateRequest = { + const payload: Record = { title: input.title, description: existing?.description ?? '', config: {}, }; const config: Record = {}; - const scope = readMemoryScope(configRecord.scope); + const scope = mapMemoryScopeToTeam(configRecord.scope); if (scope) config.scope = scope; - const collectionPrefix = readOptionalString(configRecord.collectionPrefix); - if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; + const collectionPrefix = readOptionalString(configRecord.collectionPrefix ?? configRecord.collection_prefix); + if (collectionPrefix !== undefined) config.collection_prefix = collectionPrefix; payload.config = config; return payload; } diff --git a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts index 0921ade8f..868ceb9ab 100644 --- a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts +++ b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ApiError } from '@/api/http'; import * as teamApi from '@/api/modules/teamApi'; import { TEAM_QUERY_KEYS, useTeamAgents, useTeamAttachments, useTeamMemoryBuckets, useTeamMcpServers, useTeamTools, useTeamWorkspaceConfigurations } from '@/api/hooks/team'; +import type { TeamAttachment } from '@/api/types/team'; import { useTemplates } from '@/lib/graph/hooks'; import { notifyError, notifySuccess } from '@/lib/notify'; import { @@ -26,6 +27,21 @@ function extractErrorMessage(error: unknown): string { return 'Request failed'; } +function readAttachmentId(attachment: TeamAttachment): string | undefined { + if (attachment.id && attachment.id.trim().length > 0) return attachment.id; + if (attachment.meta?.id && attachment.meta.id.trim().length > 0) return attachment.meta.id; + return undefined; +} + +function readAttachmentSideId(attachment: TeamAttachment, key: 'source' | 'target'): string | undefined { + const record = attachment as Record; + const direct = record[`${key}Id`]; + if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim(); + const snake = record[`${key}_id`]; + if (typeof snake === 'string' && snake.trim().length > 0) return snake.trim(); + return undefined; +} + export function useTeamEntities() { const qc = useQueryClient(); const templatesQuery = useTemplates(); @@ -71,8 +87,8 @@ export function useTeamEntities() { if (!relations) return; const current = await ensureAttachments(); const relevant = current.filter((attachment) => { - const sourceId = attachment.sourceId; - const targetId = attachment.targetId; + const sourceId = readAttachmentSideId(attachment, 'source'); + const targetId = readAttachmentSideId(attachment, 'target'); return sourceId === entityId || targetId === entityId; }); const desired = buildAttachmentInputsFromRelations(relations, entityId); @@ -80,7 +96,9 @@ export function useTeamEntities() { if (remove.length > 0) { await Promise.all( remove.map(async (attachment) => { - await teamApi.deleteAttachment(attachment.id); + const id = readAttachmentId(attachment); + if (!id) return; + await teamApi.deleteAttachment(id); }), ); } @@ -89,6 +107,8 @@ export function useTeamEntities() { create.map((attachment) => teamApi.createAttachment({ kind: attachment.kind, + source_id: attachment.sourceId, + target_id: attachment.targetId, sourceId: attachment.sourceId, targetId: attachment.targetId, }), @@ -104,7 +124,10 @@ export function useTeamEntities() { switch (input.entityKind) { case 'agent': { const created = await teamApi.createAgent(buildAgentRequest(input)); - await syncAttachments(created.id, input.relations); + const id = created.id ?? created.meta?.id; + if (typeof id === 'string' && id.length > 0) { + await syncAttachments(id, input.relations); + } return created; } case 'tool': { @@ -113,7 +136,10 @@ export function useTeamEntities() { } case 'mcp': { const created = await teamApi.createMcpServer(buildMcpServerRequest(input)); - await syncAttachments(created.id, input.relations); + const id = created.id ?? created.meta?.id; + if (typeof id === 'string' && id.length > 0) { + await syncAttachments(id, input.relations); + } return created; } case 'workspace': { @@ -142,27 +168,27 @@ export function useTeamEntities() { switch (input.entityKind) { case 'agent': { const payload = buildAgentRequest(input, existing); - const updated = await teamApi.updateAgent(input.id, payload); + const updated = await teamApi.updateAgent(input.id, { id: input.id, ...payload }); await syncAttachments(input.id, input.relations); return updated; } case 'tool': { const payload = buildToolRequest(input, existing); - return teamApi.updateTool(input.id, payload); + return teamApi.updateTool(input.id, { id: input.id, ...payload }); } case 'mcp': { const payload = buildMcpServerRequest(input, existing); - const updated = await teamApi.updateMcpServer(input.id, payload); + const updated = await teamApi.updateMcpServer(input.id, { id: input.id, ...payload }); await syncAttachments(input.id, input.relations); return updated; } case 'workspace': { const payload = buildWorkspaceRequest(input, existing); - return teamApi.updateWorkspaceConfiguration(input.id, payload); + return teamApi.updateWorkspaceConfiguration(input.id, { id: input.id, ...payload }); } case 'memory': { const payload = buildMemoryBucketRequest(input, existing); - return teamApi.updateMemoryBucket(input.id, payload); + return teamApi.updateMemoryBucket(input.id, { id: input.id, ...payload }); } default: throw new Error(`Unsupported entity kind: ${input.entityKind}`); diff --git a/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx b/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx index 8b656887e..906d7dbd7 100644 --- a/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx +++ b/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx @@ -70,16 +70,11 @@ function primeTeamHandlers() { items: [ { id: 'agent-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', title: 'Core Agent', description: 'Primary responder', config: { model: 'gpt-4' }, }, ], - page: 1, - perPage: 50, - total: 1, }), ), http.get(abs('/apiv2/team/v1/tools'), () => @@ -87,17 +82,12 @@ function primeTeamHandlers() { items: [ { id: 'tool-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - type: 'manage', + type: 'TOOL_TYPE_MANAGE', name: 'manage_team', description: 'Manage tool', config: { name: 'manage_team' }, }, ], - page: 1, - perPage: 50, - total: 1, }), ), http.get(abs('/apiv2/team/v1/mcp-servers'), () => @@ -105,16 +95,11 @@ function primeTeamHandlers() { items: [ { id: 'mcp-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', title: 'Filesystem MCP', description: 'Local MCP', config: { namespace: 'fs', command: 'fs' }, }, ], - page: 1, - perPage: 50, - total: 1, }), ), http.get(abs('/apiv2/team/v1/workspace-configurations'), () => @@ -122,16 +107,11 @@ function primeTeamHandlers() { items: [ { id: 'workspace-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', title: 'Worker Pool', description: 'Default workspace', config: { image: 'docker.io/library/node:18' }, }, ], - page: 1, - perPage: 50, - total: 1, }), ), http.get(abs('/apiv2/team/v1/memory-buckets'), () => @@ -139,26 +119,14 @@ function primeTeamHandlers() { items: [ { id: 'memory-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', title: 'Global Memory', description: 'Shared', - config: { scope: 'global' }, + config: { scope: 'MEMORY_BUCKET_SCOPE_GLOBAL' }, }, ], - page: 1, - perPage: 50, - total: 1, - }), - ), - http.get(abs('/apiv2/team/v1/attachments'), () => - HttpResponse.json({ - items: [], - page: 1, - perPage: 50, - total: 0, }), ), + http.get(abs('/apiv2/team/v1/attachments'), () => HttpResponse.json({ items: [] })), ); } From de33b66fde57d47e78dde0f6df893331b8391af5 Mon Sep 17 00:00:00 2001 From: casey-brooks Date: Thu, 12 Mar 2026 11:41:57 +0000 Subject: [PATCH 31/43] chore(platform-server): update devspace (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(platform-server): update devspace * fix(platform-server): align devspace workflow * fix(platform-server): move devspace ports * fix(devspace): sync to /opt/app/data to avoid CRI permission issue The container runtime creates /opt/app/data/workspace as root:node 2755 which is not writable by UID 1000. Using the emptyDir mount root /opt/app/data directly resolves this since Kubernetes sets it to 2777 with fsGroup. * fix(devspace): bootstrap pnpm via corepack The current platform-server dev image does not ship with pnpm shims on PATH. Install the shims under /opt/app/data so the non-root dev container can run pnpm during startup. * fix(devspace): bootstrap buf and graph path * chore(devspace): extract startup script * fix(devspace): add transitional pnpm fallback for stale dev image Until the dev image is rebuilt from the updated Dockerfile.dev, corepack's pnpm shim isn't on PATH. This adds a conditional fallback that will be a no-op once the image includes PNPM_HOME. * fix(devspace): use corepack shims for pnpm Avoid writing to /usr/local/bin when pnpm is missing on PATH. This prepends corepack's shims directory so stale dev images can activate pnpm without permission errors. * fix(devspace): remove transitional fallbacks, build image locally for testing * fix(devspace): use tsx watch for hot reload in dev mode * docs(devspace): fix startup step 5 to match tsx watch command * docs(devspace): remove KUBECONFIG export — apply.sh merges into ~/.kube * chore(devspace): use ghcr.io/agynio/devcontainer-node:1 for dev image Extract the dev container image to its own repository (agynio/devcontainer-node) with proper versioning and CI. Remove Dockerfile.dev and the platform-server-dev-image workflow from this repo. * fix(platform-server): stabilize litellm aliases * fix(platform-server): patch devspace in place * fix(platform): stabilize exec completion - poll exec inspect to finish gRPC streams - add exec inspect helper for docker runner - align devspace patch indentation * revert: remove out-of-scope docker-runner changes * fix: stabilize exec polling and litellm * revert: remove out-of-scope docker-runner changes (again) * fix(platform-server): avoid ncps init block * fix(platform-server): restore ncps init Add LiteLLM provisioner integration coverage. Update LiteLLM test script and helpers. * test(platform-server): add litellm error paths Add mocked provisioner error path coverage and fix key store delete in integration test. * Add .devspace to gitignore --------- Co-authored-by: Vitalii Valkov --- .../platform-server/__tests__/config.service.fromEnv.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 1293168d8..3223e425d 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -16,9 +16,6 @@ const trackedEnvKeys = [ 'AGENTS_DEPLOYMENT', 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', - 'TEAMS_SERVICE_ADDR', - 'DEPLOYMENT_ID', - 'TEAMS_SERVICE_ADDR', 'NODE_ENV', 'HOSTNAME', ]; From 659a922e28ba96971d8306855210d366578e1fc4 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 12 Mar 2026 23:35:48 +0000 Subject: [PATCH 32/43] fix(platform-ui): align team api contracts --- .../platform-ui/src/api/modules/teamApi.ts | 305 +++++++--- packages/platform-ui/src/api/types/team.ts | 199 +++--- .../__tests__/EntityUpsertForm.test.tsx | 3 + .../src/features/entities/api/teamEntities.ts | 569 +++++------------- .../entities/hooks/useTeamEntities.ts | 46 +- .../__tests__/entities-list-page.test.tsx | 38 +- 6 files changed, 541 insertions(+), 619 deletions(-) diff --git a/packages/platform-ui/src/api/modules/teamApi.ts b/packages/platform-ui/src/api/modules/teamApi.ts index b2b39fc1e..1b0795bb7 100644 --- a/packages/platform-ui/src/api/modules/teamApi.ts +++ b/packages/platform-ui/src/api/modules/teamApi.ts @@ -1,137 +1,243 @@ import { http } from '../http'; import type { TeamAgent, + TeamAgentCreateRequest, + TeamAgentUpdateRequest, TeamAttachment, + TeamAttachmentCreateRequest, + TeamAttachmentKind, + TeamEntityType, TeamListResponse, TeamMemoryBucket, + TeamMemoryBucketCreateRequest, + TeamMemoryBucketUpdateRequest, TeamMcpServer, + TeamMcpServerCreateRequest, + TeamMcpServerUpdateRequest, TeamTool, + TeamToolCreateRequest, + TeamToolType, + TeamToolUpdateRequest, TeamWorkspaceConfiguration, + TeamWorkspaceConfigurationCreateRequest, + TeamWorkspaceConfigurationUpdateRequest, } from '../types/team'; +import { isRecord, readNumber, readString } from '@/utils/typeGuards'; const TEAM_API_PREFIX = '/apiv2/team/v1'; const DEFAULT_PAGE_SIZE = 200; export type TeamListParams = { - pageToken?: string; - pageSize?: number; + page?: number; + perPage?: number; + q?: string; }; type PageInfo = { - nextPageToken?: string; - page?: number; - perPage?: number; - total?: number; + page: number; + perPage: number; + total: number; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const TOOL_TYPES = new Set([ + 'manage', + 'memory', + 'shell_command', + 'send_message', + 'send_slack_message', + 'remind_me', + 'github_clone_repo', + 'call_agent', +]); + +const ATTACHMENT_KINDS = new Set([ + 'agent_tool', + 'agent_memoryBucket', + 'agent_workspaceConfiguration', + 'agent_mcpServer', + 'mcpServer_workspaceConfiguration', +]); + +const ENTITY_TYPES = new Set([ + 'agent', + 'tool', + 'mcpServer', + 'workspaceConfiguration', + 'memoryBucket', +]); + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`Unexpected ${label} response`); + } + return value; } -function readString(value: unknown): string | undefined { - if (typeof value === 'string' && value.trim().length > 0) { - return value; +function readRequiredString(record: Record, key: string, label: string): string { + const value = readString(record[key]); + if (!value) { + throw new Error(`Unexpected ${label} response`); } - return undefined; + return value; } -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; +function readRequiredRecord(record: Record, key: string, label: string): Record { + const value = record[key]; + if (!isRecord(value)) { + throw new Error(`Unexpected ${label} response`); } - return undefined; + return value; } -function readPageInfo(record: Record): PageInfo { - const nextPageToken = readString(record.nextPageToken ?? record.next_page_token); - const page = readNumber(record.page ?? record.pageNumber ?? record.page_number); - const perPage = readNumber(record.perPage ?? record.per_page); - const total = readNumber(record.total); - return { nextPageToken, page, perPage, total }; +function readEnumValue(value: unknown, allowed: ReadonlySet, label: string): T { + const trimmed = readString(value); + if (!trimmed || !allowed.has(trimmed as T)) { + throw new Error(`Unexpected ${label} response`); + } + return trimmed as T; } -function getItems(record: Record, key: string): T[] { - const raw = record[key] ?? record.items; - return Array.isArray(raw) ? (raw as T[]) : []; +function readPageInfo(record: Record): PageInfo { + const page = readNumber(record.page); + const perPage = readNumber(record.perPage); + const total = readNumber(record.total); + if (page === undefined || perPage === undefined || total === undefined) { + throw new Error('Unexpected list response'); + } + return { page, perPage, total }; } -function parseListResponse(payload: unknown, key: string): TeamListResponse { - if (!isRecord(payload)) { - return { items: [] }; +function parseListResponse(payload: unknown, parseItem: (item: unknown) => T): TeamListResponse { + const record = requireRecord(payload, 'list'); + const items = record.items; + if (!Array.isArray(items)) { + throw new Error('Unexpected list response'); } - const items = getItems(payload, key); - const pageInfo = readPageInfo(payload); - return { items, ...pageInfo }; + const pageInfo = readPageInfo(record); + return { items: items.map((item) => parseItem(item)), ...pageInfo }; } function buildListParams(params?: TeamListParams): Record { if (!params) return {}; const result: Record = {}; - if (params.pageSize !== undefined) { - result.pageSize = params.pageSize; - result.page_size = params.pageSize; - result.per_page = params.pageSize; - } - if (params.pageToken !== undefined) { - result.pageToken = params.pageToken; - result.page_token = params.pageToken; - const parsed = Number(params.pageToken); - if (Number.isFinite(parsed)) { - result.page = parsed; - } - } + if (typeof params.page === 'number') result.page = params.page; + if (typeof params.perPage === 'number') result.perPage = params.perPage; + const query = readString(params.q); + if (query) result.q = query; return result; } -function resolveNextPageToken(pageInfo: PageInfo, pageSize: number, currentPage: number, count: number): string | undefined { - if (pageInfo.nextPageToken) return pageInfo.nextPageToken; - if (pageInfo.page !== undefined && pageInfo.perPage !== undefined && pageInfo.total !== undefined) { - if (pageInfo.page * pageInfo.perPage >= pageInfo.total) { - return undefined; - } - return String(pageInfo.page + 1); - } - if (count < pageSize) { - return undefined; - } - return String(currentPage + 1); +function parseEntityMeta(record: Record, label: string): { id: string; createdAt: string; updatedAt: string } { + return { + id: readRequiredString(record, 'id', label), + createdAt: readRequiredString(record, 'createdAt', label), + updatedAt: readRequiredString(record, 'updatedAt', label), + }; +} + +function parseAgent(raw: unknown): TeamAgent { + const record = requireRecord(raw, 'agent'); + const meta = parseEntityMeta(record, 'agent'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'agent'), + }; +} + +function parseTool(raw: unknown): TeamTool { + const record = requireRecord(raw, 'tool'); + const meta = parseEntityMeta(record, 'tool'); + return { + ...meta, + type: readEnumValue(record.type, TOOL_TYPES, 'tool'), + name: readString(record.name), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'tool'), + }; +} + +function parseMcpServer(raw: unknown): TeamMcpServer { + const record = requireRecord(raw, 'mcp server'); + const meta = parseEntityMeta(record, 'mcp server'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'mcp server'), + }; +} + +function parseWorkspaceConfiguration(raw: unknown): TeamWorkspaceConfiguration { + const record = requireRecord(raw, 'workspace configuration'); + const meta = parseEntityMeta(record, 'workspace configuration'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'workspace configuration'), + }; +} + +function parseMemoryBucket(raw: unknown): TeamMemoryBucket { + const record = requireRecord(raw, 'memory bucket'); + const meta = parseEntityMeta(record, 'memory bucket'); + return { + ...meta, + title: readString(record.title), + description: readString(record.description), + config: readRequiredRecord(record, 'config', 'memory bucket'), + }; +} + +function parseAttachment(raw: unknown): TeamAttachment { + const record = requireRecord(raw, 'attachment'); + const meta = parseEntityMeta(record, 'attachment'); + return { + ...meta, + kind: readEnumValue(record.kind, ATTACHMENT_KINDS, 'attachment kind'), + sourceId: readRequiredString(record, 'sourceId', 'attachment'), + targetId: readRequiredString(record, 'targetId', 'attachment'), + sourceType: readEnumValue(record.sourceType, ENTITY_TYPES, 'attachment sourceType'), + targetType: readEnumValue(record.targetType, ENTITY_TYPES, 'attachment targetType'), + }; } async function listAllPages( fetchPage: (params: TeamListParams) => Promise>, - pageSize = DEFAULT_PAGE_SIZE, + params?: TeamListParams, ): Promise { const items: T[] = []; - let pageToken: string | undefined = undefined; - let pageIndex = 1; + let page = params?.page ?? 1; + const perPage = params?.perPage ?? DEFAULT_PAGE_SIZE; + const q = params?.q; for (let i = 0; i < 50; i += 1) { - const response = await fetchPage({ pageSize, pageToken }); + const response = await fetchPage({ page, perPage, q }); items.push(...response.items); - const nextToken = resolveNextPageToken(response, pageSize, pageIndex, response.items.length); - if (!nextToken) break; - pageToken = nextToken; - pageIndex += 1; + if (response.page * response.perPage >= response.total) break; + page = response.page + 1; } return items; } export async function listAgents(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/agents`, { params: buildListParams(params) }); - return parseListResponse(payload, 'agents'); + return parseListResponse(payload, parseAgent); } export async function listAllAgents(): Promise { return listAllPages(listAgents); } -export async function createAgent(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/agents`, payload); +export async function createAgent(payload: TeamAgentCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/agents`, payload); + return parseAgent(response); } -export async function updateAgent(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); +export async function updateAgent(id: string, payload: TeamAgentUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/agents/${id}`, payload); + return parseAgent(response); } export async function deleteAgent(id: string): Promise { @@ -140,19 +246,21 @@ export async function deleteAgent(id: string): Promise { export async function listTools(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/tools`, { params: buildListParams(params) }); - return parseListResponse(payload, 'tools'); + return parseListResponse(payload, parseTool); } export async function listAllTools(): Promise { return listAllPages(listTools); } -export async function createTool(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/tools`, payload); +export async function createTool(payload: TeamToolCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/tools`, payload); + return parseTool(response); } -export async function updateTool(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); +export async function updateTool(id: string, payload: TeamToolUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/tools/${id}`, payload); + return parseTool(response); } export async function deleteTool(id: string): Promise { @@ -161,19 +269,21 @@ export async function deleteTool(id: string): Promise { export async function listMcpServers(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/mcp-servers`, { params: buildListParams(params) }); - return parseListResponse(payload, 'mcpServers'); + return parseListResponse(payload, parseMcpServer); } export async function listAllMcpServers(): Promise { return listAllPages(listMcpServers); } -export async function createMcpServer(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); +export async function createMcpServer(payload: TeamMcpServerCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/mcp-servers`, payload); + return parseMcpServer(response); } -export async function updateMcpServer(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); +export async function updateMcpServer(id: string, payload: TeamMcpServerUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/mcp-servers/${id}`, payload); + return parseMcpServer(response); } export async function deleteMcpServer(id: string): Promise { @@ -186,22 +296,26 @@ export async function listWorkspaceConfigurations( const payload = await http.get(`${TEAM_API_PREFIX}/workspace-configurations`, { params: buildListParams(params), }); - return parseListResponse(payload, 'workspaceConfigurations'); + return parseListResponse(payload, parseWorkspaceConfiguration); } export async function listAllWorkspaceConfigurations(): Promise { return listAllPages(listWorkspaceConfigurations); } -export async function createWorkspaceConfiguration(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); +export async function createWorkspaceConfiguration( + payload: TeamWorkspaceConfigurationCreateRequest, +): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/workspace-configurations`, payload); + return parseWorkspaceConfiguration(response); } export async function updateWorkspaceConfiguration( id: string, - payload: Record, + payload: TeamWorkspaceConfigurationUpdateRequest, ): Promise { - return http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); + const response = await http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); + return parseWorkspaceConfiguration(response); } export async function deleteWorkspaceConfiguration(id: string): Promise { @@ -210,19 +324,21 @@ export async function deleteWorkspaceConfiguration(id: string): Promise { export async function listMemoryBuckets(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/memory-buckets`, { params: buildListParams(params) }); - return parseListResponse(payload, 'memoryBuckets'); + return parseListResponse(payload, parseMemoryBucket); } export async function listAllMemoryBuckets(): Promise { return listAllPages(listMemoryBuckets); } -export async function createMemoryBucket(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); +export async function createMemoryBucket(payload: TeamMemoryBucketCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/memory-buckets`, payload); + return parseMemoryBucket(response); } -export async function updateMemoryBucket(id: string, payload: Record): Promise { - return http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); +export async function updateMemoryBucket(id: string, payload: TeamMemoryBucketUpdateRequest): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/memory-buckets/${id}`, payload); + return parseMemoryBucket(response); } export async function deleteMemoryBucket(id: string): Promise { @@ -231,15 +347,16 @@ export async function deleteMemoryBucket(id: string): Promise { export async function listAttachments(params?: TeamListParams): Promise> { const payload = await http.get(`${TEAM_API_PREFIX}/attachments`, { params: buildListParams(params) }); - return parseListResponse(payload, 'attachments'); + return parseListResponse(payload, parseAttachment); } export async function listAllAttachments(): Promise { return listAllPages(listAttachments); } -export async function createAttachment(payload: Record): Promise { - return http.post(`${TEAM_API_PREFIX}/attachments`, payload); +export async function createAttachment(payload: TeamAttachmentCreateRequest): Promise { + const response = await http.post(`${TEAM_API_PREFIX}/attachments`, payload); + return parseAttachment(response); } export async function deleteAttachment(id: string): Promise { diff --git a/packages/platform-ui/src/api/types/team.ts b/packages/platform-ui/src/api/types/team.ts index de47505b4..97108c51f 100644 --- a/packages/platform-ui/src/api/types/team.ts +++ b/packages/platform-ui/src/api/types/team.ts @@ -1,116 +1,157 @@ -export type TeamAgentWhenBusy = - | 'AGENT_WHEN_BUSY_UNSPECIFIED' - | 'AGENT_WHEN_BUSY_WAIT' - | 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; +export type TeamAgentWhenBusy = 'wait' | 'injectAfterTools'; -export type TeamAgentProcessBuffer = - | 'AGENT_PROCESS_BUFFER_UNSPECIFIED' - | 'AGENT_PROCESS_BUFFER_ALL_TOGETHER' - | 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; +export type TeamAgentProcessBuffer = 'allTogether' | 'oneByOne'; export type TeamToolType = - | 'TOOL_TYPE_UNSPECIFIED' - | 'TOOL_TYPE_MANAGE' - | 'TOOL_TYPE_MEMORY' - | 'TOOL_TYPE_SHELL_COMMAND' - | 'TOOL_TYPE_SEND_MESSAGE' - | 'TOOL_TYPE_SEND_SLACK_MESSAGE' - | 'TOOL_TYPE_REMIND_ME' - | 'TOOL_TYPE_GITHUB_CLONE_REPO' - | 'TOOL_TYPE_CALL_AGENT'; - -export type TeamWorkspacePlatform = - | 'WORKSPACE_PLATFORM_UNSPECIFIED' - | 'WORKSPACE_PLATFORM_LINUX_AMD64' - | 'WORKSPACE_PLATFORM_LINUX_ARM64' - | 'WORKSPACE_PLATFORM_AUTO'; - -export type TeamMemoryBucketScope = - | 'MEMORY_BUCKET_SCOPE_UNSPECIFIED' - | 'MEMORY_BUCKET_SCOPE_GLOBAL' - | 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - -export type TeamEntityType = - | 'ENTITY_TYPE_UNSPECIFIED' - | 'ENTITY_TYPE_AGENT' - | 'ENTITY_TYPE_TOOL' - | 'ENTITY_TYPE_MCP_SERVER' - | 'ENTITY_TYPE_WORKSPACE_CONFIGURATION' - | 'ENTITY_TYPE_MEMORY_BUCKET'; + | 'manage' + | 'memory' + | 'shell_command' + | 'send_message' + | 'send_slack_message' + | 'remind_me' + | 'github_clone_repo' + | 'call_agent'; + +export type TeamWorkspacePlatform = 'linux/amd64' | 'linux/arm64' | 'auto'; + +export type TeamMemoryBucketScope = 'global' | 'perThread'; + +export type TeamEntityType = 'agent' | 'tool' | 'mcpServer' | 'workspaceConfiguration' | 'memoryBucket'; export type TeamAttachmentKind = - | 'ATTACHMENT_KIND_UNSPECIFIED' - | 'ATTACHMENT_KIND_AGENT_TOOL' - | 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET' - | 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION' - | 'ATTACHMENT_KIND_AGENT_MCP_SERVER' - | 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION'; + | 'agent_tool' + | 'agent_memoryBucket' + | 'agent_workspaceConfiguration' + | 'agent_mcpServer' + | 'mcpServer_workspaceConfiguration'; export interface TeamListResponse { items: T[]; - nextPageToken?: string; - page?: number; - perPage?: number; - total?: number; + page: number; + perPage: number; + total: number; } export interface TeamAgent { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamTool { - id?: string; - createdAt?: string; - updatedAt?: string; - type?: TeamToolType | string | number; + id: string; + createdAt: string; + updatedAt: string; + type: TeamToolType; name?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamMcpServer { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamWorkspaceConfiguration { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamMemoryBucket { - id?: string; - createdAt?: string; - updatedAt?: string; + id: string; + createdAt: string; + updatedAt: string; title?: string; description?: string; - config?: Record | null; - meta?: { id?: string }; + config: Record; } export interface TeamAttachment { - id?: string; - kind?: TeamAttachmentKind | string | number; - sourceId?: string; - targetId?: string; - sourceType?: TeamEntityType | string | number; - targetType?: TeamEntityType | string | number; - meta?: { id?: string }; + id: string; + createdAt: string; + updatedAt: string; + kind: TeamAttachmentKind; + sourceId: string; + targetId: string; + sourceType: TeamEntityType; + targetType: TeamEntityType; +} + +export interface TeamAgentCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamAgentUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamToolCreateRequest { + type: TeamToolType; + name?: string; + description?: string; + config?: Record; +} + +export interface TeamToolUpdateRequest { + name?: string; + description?: string; + config?: Record; +} + +export interface TeamMcpServerCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamMcpServerUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamWorkspaceConfigurationCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamWorkspaceConfigurationUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamMemoryBucketCreateRequest { + title?: string; + description?: string; + config: Record; +} + +export interface TeamMemoryBucketUpdateRequest { + title?: string; + description?: string; + config?: Record; +} + +export interface TeamAttachmentCreateRequest { + kind: TeamAttachmentKind; + sourceId: string; + targetId: string; } diff --git a/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx b/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx index cf435124b..ceb7e4432 100644 --- a/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx +++ b/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx @@ -493,6 +493,9 @@ describe('EntityUpsertForm', () => { id: 'mcpServerWorkspace', selections: ['workspace-1'], attachmentKind: TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration, +======= + attachmentKind: 'agent_memoryBucket', +>>>>>>> e9a06cd8 (fix(platform-ui): align team api contracts) }), ]), ); diff --git a/packages/platform-ui/src/features/entities/api/teamEntities.ts b/packages/platform-ui/src/features/entities/api/teamEntities.ts index 563272a4c..919b55aa5 100644 --- a/packages/platform-ui/src/features/entities/api/teamEntities.ts +++ b/packages/platform-ui/src/features/entities/api/teamEntities.ts @@ -1,20 +1,26 @@ import type { TemplateSchema } from '@/api/types/graph'; import type { TeamAgent, + TeamAgentCreateRequest, TeamAttachment, TeamAttachmentKind, TeamMemoryBucket, + TeamMemoryBucketCreateRequest, TeamMemoryBucketScope, TeamMcpServer, + TeamMcpServerCreateRequest, TeamTool, + TeamToolCreateRequest, TeamToolType, TeamWorkspaceConfiguration, + TeamWorkspaceConfigurationCreateRequest, TeamWorkspacePlatform, } from '@/api/types/team'; import type { AgentQueueConfig, NodeConfig } from '@/components/nodeProperties/types'; import { readEnvList, readQueueConfig, readSummarizationConfig } from '@/components/nodeProperties/utils'; import { buildGraphNodeFromTemplate } from '@/features/graph/mappers'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; +import { isRecord, readNumber, readString } from '@/utils/typeGuards'; import type { GraphEdgeFilter, GraphEntityKind, @@ -28,34 +34,33 @@ export const EXCLUDED_WORKSPACE_TEMPLATES = new Set(['memory', 'memoryConnector' export const INCLUDED_MEMORY_TEMPLATES = new Set(['memory']); export const TEAM_ATTACHMENT_KIND = { - agentTool: 'ATTACHMENT_KIND_AGENT_TOOL', - agentMemoryBucket: 'ATTACHMENT_KIND_AGENT_MEMORY_BUCKET', - agentWorkspaceConfiguration: 'ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION', - agentMcpServer: 'ATTACHMENT_KIND_AGENT_MCP_SERVER', - mcpServerWorkspaceConfiguration: 'ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION', + agentTool: 'agent_tool', + agentMemoryBucket: 'agent_memoryBucket', + agentWorkspaceConfiguration: 'agent_workspaceConfiguration', + agentMcpServer: 'agent_mcpServer', + mcpServerWorkspaceConfiguration: 'mcpServer_workspaceConfiguration', } as const satisfies Record; export const TEAM_TOOL_TYPE = { - manage: 'TOOL_TYPE_MANAGE', - memory: 'TOOL_TYPE_MEMORY', - shellCommand: 'TOOL_TYPE_SHELL_COMMAND', - sendMessage: 'TOOL_TYPE_SEND_MESSAGE', - sendSlackMessage: 'TOOL_TYPE_SEND_SLACK_MESSAGE', - remindMe: 'TOOL_TYPE_REMIND_ME', - githubCloneRepo: 'TOOL_TYPE_GITHUB_CLONE_REPO', - callAgent: 'TOOL_TYPE_CALL_AGENT', + manage: 'manage', + memory: 'memory', + shellCommand: 'shell_command', + sendMessage: 'send_message', + sendSlackMessage: 'send_slack_message', + remindMe: 'remind_me', + githubCloneRepo: 'github_clone_repo', + callAgent: 'call_agent', } as const satisfies Record; const TOOL_TYPE_TO_TEMPLATE: Record = { - TOOL_TYPE_UNSPECIFIED: 'tool', - TOOL_TYPE_MANAGE: 'manageTool', - TOOL_TYPE_MEMORY: 'memoryTool', - TOOL_TYPE_SHELL_COMMAND: 'shellTool', - TOOL_TYPE_SEND_MESSAGE: 'sendMessageTool', - TOOL_TYPE_SEND_SLACK_MESSAGE: 'sendSlackMessageTool', - TOOL_TYPE_REMIND_ME: 'remindMeTool', - TOOL_TYPE_GITHUB_CLONE_REPO: 'githubCloneRepoTool', - TOOL_TYPE_CALL_AGENT: 'callAgentTool', + manage: 'manageTool', + memory: 'memoryTool', + shell_command: 'shellTool', + send_message: 'sendMessageTool', + send_slack_message: 'sendSlackMessageTool', + remind_me: 'remindMeTool', + github_clone_repo: 'githubCloneRepoTool', + call_agent: 'callAgentTool', }; const TEMPLATE_TO_TOOL_TYPE: Record = Object.entries(TOOL_TYPE_TO_TEMPLATE).reduce( @@ -76,12 +81,11 @@ const ENTITY_KIND_TO_NODE_KIND: Record }; const ATTACHMENT_KIND_HANDLES: Record = { - ATTACHMENT_KIND_UNSPECIFIED: { sourceHandle: '$self', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_TOOL: { sourceHandle: 'tools', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_MEMORY_BUCKET: { sourceHandle: 'memory', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, - ATTACHMENT_KIND_AGENT_MCP_SERVER: { sourceHandle: 'mcp', targetHandle: '$self' }, - ATTACHMENT_KIND_MCP_SERVER_WORKSPACE_CONFIGURATION: { sourceHandle: 'workspace', targetHandle: '$self' }, + agent_tool: { sourceHandle: 'tools', targetHandle: '$self' }, + agent_memoryBucket: { sourceHandle: 'memory', targetHandle: '$self' }, + agent_workspaceConfiguration: { sourceHandle: 'workspace', targetHandle: '$self' }, + agent_mcpServer: { sourceHandle: 'mcp', targetHandle: '$self' }, + mcpServer_workspaceConfiguration: { sourceHandle: 'workspace', targetHandle: '$self' }, }; export type TeamAttachmentInput = { @@ -90,17 +94,6 @@ export type TeamAttachmentInput = { targetId: string; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim(); - } - return undefined; -} - function readOptionalString(value: unknown): string | undefined { if (typeof value === 'string') { return value; @@ -108,15 +101,6 @@ function readOptionalString(value: unknown): string | undefined { return undefined; } -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -} - function readBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') return value; if (typeof value === 'string') { @@ -126,256 +110,51 @@ function readBoolean(value: unknown): boolean | undefined { return undefined; } -function readField(record: Record, keys: string[], reader: (value: unknown) => T | undefined): T | undefined { - for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(record, key)) continue; - const value = reader(record[key]); - if (value !== undefined) return value; - } - return undefined; -} - -function normalizeEnumName(value: unknown): string { - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - if (typeof value === 'string') { - return value.trim().toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); - } - return ''; -} - -function normalizeTeamToolType(value: unknown): TeamToolType | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return TEAM_TOOL_TYPE.manage; - case 2: - return TEAM_TOOL_TYPE.memory; - case 3: - return TEAM_TOOL_TYPE.shellCommand; - case 4: - return TEAM_TOOL_TYPE.sendMessage; - case 5: - return TEAM_TOOL_TYPE.sendSlackMessage; - case 6: - return TEAM_TOOL_TYPE.remindMe; - case 7: - return TEAM_TOOL_TYPE.githubCloneRepo; - case 8: - return TEAM_TOOL_TYPE.callAgent; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('TOOL_TYPE_')) { - return normalized as TeamToolType; - } - switch (normalized) { - case 'MANAGE': - return TEAM_TOOL_TYPE.manage; - case 'MEMORY': - return TEAM_TOOL_TYPE.memory; - case 'SHELL_COMMAND': - case 'SHELL': - return TEAM_TOOL_TYPE.shellCommand; - case 'SEND_MESSAGE': - return TEAM_TOOL_TYPE.sendMessage; - case 'SEND_SLACK_MESSAGE': - return TEAM_TOOL_TYPE.sendSlackMessage; - case 'REMIND_ME': - return TEAM_TOOL_TYPE.remindMe; - case 'GITHUB_CLONE_REPO': - return TEAM_TOOL_TYPE.githubCloneRepo; - case 'CALL_AGENT': - return TEAM_TOOL_TYPE.callAgent; - default: - return undefined; - } -} - -function normalizeAttachmentKind(value: unknown): TeamAttachmentKind | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return TEAM_ATTACHMENT_KIND.agentTool; - case 2: - return TEAM_ATTACHMENT_KIND.agentMemoryBucket; - case 3: - return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; - case 4: - return TEAM_ATTACHMENT_KIND.agentMcpServer; - case 5: - return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('ATTACHMENT_KIND_')) { - if (normalized === 'ATTACHMENT_KIND_UNSPECIFIED') return undefined; - return normalized as TeamAttachmentKind; - } - switch (normalized) { - case 'AGENT_TOOL': - return TEAM_ATTACHMENT_KIND.agentTool; - case 'AGENT_MEMORY_BUCKET': - return TEAM_ATTACHMENT_KIND.agentMemoryBucket; - case 'AGENT_WORKSPACE_CONFIGURATION': - return TEAM_ATTACHMENT_KIND.agentWorkspaceConfiguration; - case 'AGENT_MCP_SERVER': - return TEAM_ATTACHMENT_KIND.agentMcpServer; - case 'MCP_SERVER_WORKSPACE_CONFIGURATION': - return TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration; - default: - return undefined; - } -} - -function normalizeWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return 'WORKSPACE_PLATFORM_LINUX_AMD64'; - case 2: - return 'WORKSPACE_PLATFORM_LINUX_ARM64'; - case 3: - return 'WORKSPACE_PLATFORM_AUTO'; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('WORKSPACE_PLATFORM_')) { - return normalized as TeamWorkspacePlatform; - } - switch (normalized) { - case 'LINUX_AMD64': - case 'LINUX_AMD_64': - case 'LINUX/AMD64': - return 'WORKSPACE_PLATFORM_LINUX_AMD64'; - case 'LINUX_ARM64': - case 'LINUX_ARM_64': - case 'LINUX/ARM64': - return 'WORKSPACE_PLATFORM_LINUX_ARM64'; - case 'AUTO': - return 'WORKSPACE_PLATFORM_AUTO'; - default: - return undefined; - } -} - -function normalizeMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { - if (typeof value === 'number') { - switch (value) { - case 1: - return 'MEMORY_BUCKET_SCOPE_GLOBAL'; - case 2: - return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - default: - return undefined; - } - } - const normalized = normalizeEnumName(value); - if (normalized.startsWith('MEMORY_BUCKET_SCOPE_')) { - return normalized as TeamMemoryBucketScope; - } - switch (normalized) { - case 'GLOBAL': - return 'MEMORY_BUCKET_SCOPE_GLOBAL'; - case 'PER_THREAD': - case 'PERTHREAD': - return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - default: - return undefined; - } -} - type AgentQueueWhenBusy = NonNullable; type AgentQueueProcessBuffer = NonNullable; +const QUEUE_WHEN_BUSY_VALUES: AgentQueueWhenBusy[] = ['wait', 'injectAfterTools']; +const QUEUE_PROCESS_BUFFER_VALUES: AgentQueueProcessBuffer[] = ['allTogether', 'oneByOne']; +const WORKSPACE_PLATFORM_VALUES: TeamWorkspacePlatform[] = ['linux/amd64', 'linux/arm64', 'auto']; +const MEMORY_SCOPE_VALUES: TeamMemoryBucketScope[] = ['global', 'perThread']; -function parseWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { - if (typeof value === 'number') { - if (value === 1) return 'wait'; - if (value === 2) return 'injectAfterTools'; - } - const normalized = normalizeEnumName(value); - if (normalized.includes('WAIT')) return 'wait'; - if (normalized.includes('INJECT_AFTER_TOOLS')) return 'injectAfterTools'; - return undefined; -} - -function parseProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { - if (typeof value === 'number') { - if (value === 1) return 'allTogether'; - if (value === 2) return 'oneByOne'; - } - const normalized = normalizeEnumName(value); - if (normalized.includes('ALL_TOGETHER')) return 'allTogether'; - if (normalized.includes('ONE_BY_ONE')) return 'oneByOne'; - return undefined; -} - -function mapWorkspacePlatformToUi(value: unknown): string | undefined { - const normalized = normalizeWorkspacePlatform(value); - switch (normalized) { - case 'WORKSPACE_PLATFORM_LINUX_AMD64': - return 'linux/amd64'; - case 'WORKSPACE_PLATFORM_LINUX_ARM64': - return 'linux/arm64'; - case 'WORKSPACE_PLATFORM_AUTO': - return 'auto'; - default: - return undefined; +function readEnumValue(value: unknown, allowed: readonly T[], label: string): T | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') { + throw new Error(`Unexpected ${label} value`); } -} - -function mapWorkspacePlatformToTeam(value: unknown): TeamWorkspacePlatform | undefined { - if (typeof value === 'string') { - const trimmed = value.trim().toLowerCase(); - if (trimmed === 'linux/amd64') return 'WORKSPACE_PLATFORM_LINUX_AMD64'; - if (trimmed === 'linux/arm64') return 'WORKSPACE_PLATFORM_LINUX_ARM64'; - if (trimmed === 'auto') return 'WORKSPACE_PLATFORM_AUTO'; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!allowed.includes(trimmed as T)) { + throw new Error(`Unexpected ${label} value`); } - return normalizeWorkspacePlatform(value); + return trimmed as T; } -function mapMemoryScopeToUi(value: unknown): string | undefined { - const normalized = normalizeMemoryScope(value); - if (normalized === 'MEMORY_BUCKET_SCOPE_GLOBAL') return 'global'; - if (normalized === 'MEMORY_BUCKET_SCOPE_PER_THREAD') return 'perThread'; - return undefined; +function readQueueWhenBusy(value: unknown): AgentQueueWhenBusy | undefined { + return readEnumValue(value, QUEUE_WHEN_BUSY_VALUES, 'agent whenBusy'); } -function mapMemoryScopeToTeam(value: unknown): TeamMemoryBucketScope | undefined { - if (typeof value === 'string') { - const trimmed = value.trim().toLowerCase(); - if (trimmed === 'global') return 'MEMORY_BUCKET_SCOPE_GLOBAL'; - if (trimmed === 'perthread' || trimmed === 'per_thread') return 'MEMORY_BUCKET_SCOPE_PER_THREAD'; - } - return normalizeMemoryScope(value); +function readQueueProcessBuffer(value: unknown): AgentQueueProcessBuffer | undefined { + return readEnumValue(value, QUEUE_PROCESS_BUFFER_VALUES, 'agent processBuffer'); } -function mapWhenBusyToTeam(value: unknown): string | undefined { - if (value === 'wait') return 'AGENT_WHEN_BUSY_WAIT'; - if (value === 'injectAfterTools') return 'AGENT_WHEN_BUSY_INJECT_AFTER_TOOLS'; - return undefined; +function readWorkspacePlatform(value: unknown): TeamWorkspacePlatform | undefined { + return readEnumValue(value, WORKSPACE_PLATFORM_VALUES, 'workspace platform'); } -function mapProcessBufferToTeam(value: unknown): string | undefined { - if (value === 'allTogether') return 'AGENT_PROCESS_BUFFER_ALL_TOGETHER'; - if (value === 'oneByOne') return 'AGENT_PROCESS_BUFFER_ONE_BY_ONE'; - return undefined; +function readMemoryScope(value: unknown): TeamMemoryBucketScope | undefined { + return readEnumValue(value, MEMORY_SCOPE_VALUES, 'memory bucket scope'); } function mapAgentConfigFromTeam(raw: Record): Record { const config: Record = {}; - const model = readField(raw, ['model'], readString); + const model = readString(raw.model); if (model) config.model = model; - const systemPrompt = readField(raw, ['systemPrompt', 'system_prompt'], readString); + const systemPrompt = readOptionalString(raw.systemPrompt); if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; - const debounceMs = readField(raw, ['debounceMs', 'debounce_ms'], readNumber); - const whenBusy = parseWhenBusy(readField(raw, ['whenBusy', 'when_busy'], (value) => value)); - const processBuffer = parseProcessBuffer(readField(raw, ['processBuffer', 'process_buffer'], (value) => value)); + const debounceMs = readNumber(raw.debounceMs); + const whenBusy = readQueueWhenBusy(raw.whenBusy); + const processBuffer = readQueueProcessBuffer(raw.processBuffer); if (debounceMs !== undefined || whenBusy || processBuffer) { const queue: Record = {}; if (debounceMs !== undefined) queue.debounceMs = debounceMs; @@ -383,43 +162,27 @@ function mapAgentConfigFromTeam(raw: Record): Record = {}; if (summarizationKeepTokens !== undefined) summarization.keepTokens = summarizationKeepTokens; if (summarizationMaxTokens !== undefined) summarization.maxTokens = summarizationMaxTokens; config.summarization = summarization; } - const restrictOutput = readField(raw, ['restrictOutput', 'restrict_output'], readBoolean); + const restrictOutput = readBoolean(raw.restrictOutput); if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; - const restrictionMessage = readField(raw, ['restrictionMessage', 'restriction_message'], readString); + const restrictionMessage = readOptionalString(raw.restrictionMessage); if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; - const restrictionMaxInjections = readField( - raw, - ['restrictionMaxInjections', 'restriction_max_injections'], - readNumber, - ); + const restrictionMaxInjections = readNumber(raw.restrictionMaxInjections); if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; - const name = readField(raw, ['name'], readString); + const name = readString(raw.name); if (name) config.name = name; - const role = readField(raw, ['role'], readString); + const role = readString(raw.role); if (role) config.role = role; return config; } @@ -435,27 +198,27 @@ function mapToolConfigFromTeam(raw: Record, tool: TeamTool): Re function mapMcpConfigFromTeam(raw: Record): Record { const config: Record = {}; - const namespace = readField(raw, ['namespace'], readString); + const namespace = readString(raw.namespace); if (namespace) config.namespace = namespace; - const command = readField(raw, ['command'], readString); + const command = readString(raw.command); if (command) config.command = command; - const workdir = readField(raw, ['workdir'], readString); + const workdir = readString(raw.workdir); if (workdir) config.workdir = workdir; - const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); + const env = Array.isArray(raw.env) ? raw.env : undefined; if (env) config.env = env; - const requestTimeoutMs = readField(raw, ['requestTimeoutMs', 'request_timeout_ms'], readNumber); + const requestTimeoutMs = readNumber(raw.requestTimeoutMs); if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; - const startupTimeoutMs = readField(raw, ['startupTimeoutMs', 'startup_timeout_ms'], readNumber); + const startupTimeoutMs = readNumber(raw.startupTimeoutMs); if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; - const heartbeatIntervalMs = readField(raw, ['heartbeatIntervalMs', 'heartbeat_interval_ms'], readNumber); + const heartbeatIntervalMs = readNumber(raw.heartbeatIntervalMs); if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; - const staleTimeoutMs = readField(raw, ['staleTimeoutMs', 'stale_timeout_ms'], readNumber); + const staleTimeoutMs = readNumber(raw.staleTimeoutMs); if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; - const restart = readField(raw, ['restart'], (value) => (isRecord(value) ? value : undefined)); + const restart = isRecord(raw.restart) ? raw.restart : undefined; if (restart) { const restartConfig: Record = {}; - const maxAttempts = readField(restart, ['maxAttempts', 'max_attempts'], readNumber); - const backoffMs = readField(restart, ['backoffMs', 'backoff_ms'], readNumber); + const maxAttempts = readNumber(restart.maxAttempts); + const backoffMs = readNumber(restart.backoffMs); if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; config.restart = restartConfig; @@ -465,31 +228,32 @@ function mapMcpConfigFromTeam(raw: Record): Record): Record { const config: Record = {}; - const image = readField(raw, ['image'], readString); + const image = readString(raw.image); if (image) config.image = image; - const env = readField(raw, ['env'], (value) => (Array.isArray(value) ? value : undefined)); + const env = Array.isArray(raw.env) ? raw.env : undefined; if (env) config.env = env; - const initialScript = readField(raw, ['initialScript', 'initial_script'], readOptionalString); + const initialScript = readOptionalString(raw.initialScript); if (initialScript !== undefined) config.initialScript = initialScript; - const cpuLimit = readField(raw, ['cpu_limit', 'cpuLimit'], (value) => value as unknown); - if (cpuLimit !== undefined) config.cpu_limit = cpuLimit; - const memoryLimit = readField(raw, ['memory_limit', 'memoryLimit'], (value) => value as unknown); - if (memoryLimit !== undefined) config.memory_limit = memoryLimit; - const platform = readField(raw, ['platform'], (value) => value as unknown); - const platformValue = mapWorkspacePlatformToUi(platform); - if (platformValue) config.platform = platformValue; - const enableDinD = readField(raw, ['enableDinD', 'enable_dind', 'enableDind'], readBoolean); + if (typeof raw.cpuLimit === 'string' || typeof raw.cpuLimit === 'number') { + config.cpu_limit = raw.cpuLimit; + } + if (typeof raw.memoryLimit === 'string' || typeof raw.memoryLimit === 'number') { + config.memory_limit = raw.memoryLimit; + } + const platform = readWorkspacePlatform(raw.platform); + if (platform) config.platform = platform; + const enableDinD = readBoolean(raw.enableDinD); if (enableDinD !== undefined) config.enableDinD = enableDinD; - const ttlSeconds = readField(raw, ['ttlSeconds', 'ttl_seconds'], readNumber); + const ttlSeconds = readNumber(raw.ttlSeconds); if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; - const nix = readField(raw, ['nix'], (value) => (isRecord(value) ? value : undefined)); + const nix = isRecord(raw.nix) ? raw.nix : undefined; if (nix) config.nix = nix; - const volumes = readField(raw, ['volumes'], (value) => (isRecord(value) ? value : undefined)); + const volumes = isRecord(raw.volumes) ? raw.volumes : undefined; if (volumes) { const volumeConfig: Record = {}; - const enabled = readField(volumes, ['enabled'], readBoolean); + const enabled = readBoolean(volumes.enabled); if (enabled !== undefined) volumeConfig.enabled = enabled; - const mountPath = readField(volumes, ['mountPath', 'mount_path'], readOptionalString); + const mountPath = readOptionalString(volumes.mountPath); if (mountPath !== undefined) volumeConfig.mountPath = mountPath; config.volumes = volumeConfig; } @@ -498,10 +262,9 @@ function mapWorkspaceConfigFromTeam(raw: Record): Record): Record { const config: Record = {}; - const scope = readField(raw, ['scope'], (value) => value as unknown); - const scopeValue = mapMemoryScopeToUi(scope); + const scopeValue = readMemoryScope(raw.scope); if (scopeValue) config.scope = scopeValue; - const collectionPrefix = readField(raw, ['collectionPrefix', 'collection_prefix'], readOptionalString); + const collectionPrefix = readOptionalString(raw.collectionPrefix); if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; return config; } @@ -649,11 +412,10 @@ export function mapTeamEntities( for (const agent of sources.agents ?? []) { if (!agent) continue; - const id = readString(agent.id ?? agent.meta?.id); - if (!id) continue; + const id = agent.id; const template = selectTemplate(templates, 'agent', { preferredNames: ['agent'] }); const templateName = template?.name ?? 'agent'; - const config = mapAgentConfigFromTeam(isRecord(agent.config) ? agent.config : {}); + const config = mapAgentConfigFromTeam(agent.config); const title = resolveEntityTitle(readString(agent.title) ?? template?.title ?? templateName) || templateName; addSummary({ id, @@ -672,13 +434,12 @@ export function mapTeamEntities( for (const tool of sources.tools ?? []) { if (!tool) continue; - const id = readString(tool.id ?? tool.meta?.id); - if (!id) continue; - const toolType = normalizeTeamToolType(tool.type); - const templateName = (toolType && TOOL_TYPE_TO_TEMPLATE[toolType]) || 'tool'; + const id = tool.id; + const toolType = tool.type; + const templateName = TOOL_TYPE_TO_TEMPLATE[toolType] ?? 'tool'; const template = templates.find((entry) => entry.name === templateName) ?? selectTemplate(templates, 'tool'); - const config = mapToolConfigFromTeam(isRecord(tool.config) ? tool.config : {}, tool); + const config = mapToolConfigFromTeam(tool.config, tool); const titleCandidate = readString(tool.description) ?? readString(tool.name) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -700,11 +461,10 @@ export function mapTeamEntities( for (const mcpServer of sources.mcpServers ?? []) { if (!mcpServer) continue; - const id = readString(mcpServer.id ?? mcpServer.meta?.id); - if (!id) continue; + const id = mcpServer.id; const template = selectTemplate(templates, 'mcp', { preferredNames: ['mcpServer', 'mcp'] }); const templateName = template?.name ?? 'mcp'; - const config = mapMcpConfigFromTeam(isRecord(mcpServer.config) ? mcpServer.config : {}); + const config = mapMcpConfigFromTeam(mcpServer.config); const titleCandidate = readString(mcpServer.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -724,14 +484,13 @@ export function mapTeamEntities( for (const workspace of sources.workspaceConfigurations ?? []) { if (!workspace) continue; - const id = readString(workspace.id ?? workspace.meta?.id); - if (!id) continue; + const id = workspace.id; const template = selectTemplate(templates, 'workspace', { preferredNames: ['workspace'], excludeNames: EXCLUDED_WORKSPACE_TEMPLATES, }); const templateName = template?.name ?? 'workspace'; - const config = mapWorkspaceConfigFromTeam(isRecord(workspace.config) ? workspace.config : {}); + const config = mapWorkspaceConfigFromTeam(workspace.config); const titleCandidate = readString(workspace.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -751,14 +510,13 @@ export function mapTeamEntities( for (const memory of sources.memoryBuckets ?? []) { if (!memory) continue; - const id = readString(memory.id ?? memory.meta?.id); - if (!id) continue; + const id = memory.id; const template = selectTemplate(templates, 'memory', { preferredNames: ['memory'], includeNames: INCLUDED_MEMORY_TEMPLATES, }); const templateName = template?.name ?? 'memory'; - const config = mapMemoryBucketConfigFromTeam(isRecord(memory.config) ? memory.config : {}); + const config = mapMemoryBucketConfigFromTeam(memory.config); const titleCandidate = readString(memory.title) ?? template?.title ?? templateName; const title = resolveEntityTitle(titleCandidate) || templateName; addSummary({ @@ -805,12 +563,9 @@ export function mapTeamAttachmentsToEdges(attachments: TeamAttachment[] | undefi const edges: GraphPersistedEdge[] = []; for (const attachment of attachments) { if (!attachment) continue; - const kind = normalizeAttachmentKind(attachment.kind); - if (!kind) continue; - const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); - const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); - if (!sourceId || !targetId) continue; - const handles = ATTACHMENT_KIND_HANDLES[kind]; + const handles = ATTACHMENT_KIND_HANDLES[attachment.kind]; + const sourceId = attachment.sourceId; + const targetId = attachment.targetId; const id = buildEdgeId(sourceId, handles.sourceHandle, targetId, handles.targetHandle); edges.push({ id, @@ -857,10 +612,6 @@ export function mapTeamEntitiesToGraphNodes( }); } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - function isEnvEntryRecord(value: Record): boolean { return typeof value.name === 'string' && Object.prototype.hasOwnProperty.call(value, 'value'); } @@ -879,13 +630,13 @@ function sanitizeEnvEntry(entry: Record): Record { - if (isPlainRecord(item) && isEnvEntryRecord(item)) { + if (isRecord(item) && isEnvEntryRecord(item)) { return sanitizeEnvEntry(item); } return sanitizeConfigValue(item); }); } - if (isPlainRecord(value)) { + if (isRecord(value)) { if (isEnvEntryRecord(value)) { return sanitizeEnvEntry(value); } @@ -899,7 +650,7 @@ function sanitizeConfigValue(value: unknown): unknown { } export function sanitizeConfigForPersistence(_templateName: string, config: Record | undefined): Record { - const base = isPlainRecord(config) ? config : {}; + const base = isRecord(config) ? config : {}; const sanitized: Record = {}; for (const [key, value] of Object.entries(base)) { if (key === 'title' || key === 'template' || key === 'kind') { @@ -946,13 +697,8 @@ export function diffTeamAttachments( const normalizedCurrent: Array<{ key: string; attachment: TeamAttachment }> = []; for (const attachment of current ?? []) { - const kind = normalizeAttachmentKind(attachment.kind); - if (!kind) continue; - const sourceId = readString((attachment as Record).sourceId ?? (attachment as Record).source_id); - const targetId = readString((attachment as Record).targetId ?? (attachment as Record).target_id); - if (!sourceId || !targetId) continue; - const key = `${kind}:${sourceId}:${targetId}`; - normalizedCurrent.push({ key, attachment: { ...attachment, kind } }); + const key = `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; + normalizedCurrent.push({ key, attachment }); } const currentKeys = new Set(normalizedCurrent.map((entry) => entry.key)); @@ -969,11 +715,11 @@ function mapEnvListForTeam(env: unknown): Array<{ name: string; value: string }> .filter((item) => item.name.length > 0); } -export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): TeamAgentCreateRequest { const configRecord = input.config as Record; const queue = readQueueConfig(configRecord as NodeConfig); const summarization = readSummarizationConfig(configRecord as NodeConfig); - const payload: Record = { + const payload: TeamAgentCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, @@ -982,20 +728,20 @@ export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: Grap const model = readOptionalString(configRecord.model); if (model !== undefined) config.model = model; const systemPrompt = readOptionalString(configRecord.systemPrompt); - if (systemPrompt !== undefined) config.system_prompt = systemPrompt; - if (queue.debounceMs !== undefined) config.debounce_ms = queue.debounceMs; - if (queue.whenBusy) config.when_busy = mapWhenBusyToTeam(queue.whenBusy); - if (queue.processBuffer) config.process_buffer = mapProcessBufferToTeam(queue.processBuffer); + if (systemPrompt !== undefined) config.systemPrompt = systemPrompt; + if (queue.debounceMs !== undefined) config.debounceMs = queue.debounceMs; + if (queue.whenBusy) config.whenBusy = queue.whenBusy; + if (queue.processBuffer) config.processBuffer = queue.processBuffer; const sendFinal = readBoolean(configRecord.sendFinalResponseToThread); - if (sendFinal !== undefined) config.send_final_response_to_thread = sendFinal; - if (summarization.keepTokens !== undefined) config.summarization_keep_tokens = summarization.keepTokens; - if (summarization.maxTokens !== undefined) config.summarization_max_tokens = summarization.maxTokens; + if (sendFinal !== undefined) config.sendFinalResponseToThread = sendFinal; + if (summarization.keepTokens !== undefined) config.summarizationKeepTokens = summarization.keepTokens; + if (summarization.maxTokens !== undefined) config.summarizationMaxTokens = summarization.maxTokens; const restrictOutput = readBoolean(configRecord.restrictOutput); - if (restrictOutput !== undefined) config.restrict_output = restrictOutput; + if (restrictOutput !== undefined) config.restrictOutput = restrictOutput; const restrictionMessage = readOptionalString(configRecord.restrictionMessage); - if (restrictionMessage !== undefined) config.restriction_message = restrictionMessage; + if (restrictionMessage !== undefined) config.restrictionMessage = restrictionMessage; const restrictionMaxInjections = readNumber(configRecord.restrictionMaxInjections); - if (restrictionMaxInjections !== undefined) config.restriction_max_injections = restrictionMaxInjections; + if (restrictionMaxInjections !== undefined) config.restrictionMaxInjections = restrictionMaxInjections; const name = readOptionalString(configRecord.name); if (name !== undefined) config.name = name; const role = readOptionalString(configRecord.role); @@ -1004,7 +750,7 @@ export function buildAgentRequest(input: GraphEntityUpsertInput, existing?: Grap return payload; } -export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildToolRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): TeamToolCreateRequest { const configRecord = input.config as Record; const toolType = TEMPLATE_TO_TOOL_TYPE[input.template] ?? existing?.toolType ?? TEAM_TOOL_TYPE.manage; const toolName = readOptionalString(configRecord.name) ?? existing?.toolName ?? input.title; @@ -1016,9 +762,12 @@ export function buildToolRequest(input: GraphEntityUpsertInput, existing?: Graph }; } -export function buildMcpServerRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildMcpServerRequest( + input: GraphEntityUpsertInput, + existing?: GraphEntitySummary, +): TeamMcpServerCreateRequest { const configRecord = input.config as Record; - const payload: Record = { + const payload: TeamMcpServerCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, @@ -1033,29 +782,32 @@ export function buildMcpServerRequest(input: GraphEntityUpsertInput, existing?: const env = mapEnvListForTeam(configRecord.env); if (env.length > 0) config.env = env; const requestTimeoutMs = readNumber(configRecord.requestTimeoutMs); - if (requestTimeoutMs !== undefined) config.request_timeout_ms = requestTimeoutMs; + if (requestTimeoutMs !== undefined) config.requestTimeoutMs = requestTimeoutMs; const startupTimeoutMs = readNumber(configRecord.startupTimeoutMs); - if (startupTimeoutMs !== undefined) config.startup_timeout_ms = startupTimeoutMs; + if (startupTimeoutMs !== undefined) config.startupTimeoutMs = startupTimeoutMs; const heartbeatIntervalMs = readNumber(configRecord.heartbeatIntervalMs); - if (heartbeatIntervalMs !== undefined) config.heartbeat_interval_ms = heartbeatIntervalMs; + if (heartbeatIntervalMs !== undefined) config.heartbeatIntervalMs = heartbeatIntervalMs; const staleTimeoutMs = readNumber(configRecord.staleTimeoutMs); - if (staleTimeoutMs !== undefined) config.stale_timeout_ms = staleTimeoutMs; + if (staleTimeoutMs !== undefined) config.staleTimeoutMs = staleTimeoutMs; const restart = isRecord(configRecord.restart) ? configRecord.restart : {}; const maxAttempts = readNumber(restart.maxAttempts); const backoffMs = readNumber(restart.backoffMs); if (maxAttempts !== undefined || backoffMs !== undefined) { const restartConfig: Record = {}; - if (maxAttempts !== undefined) restartConfig.max_attempts = maxAttempts; - if (backoffMs !== undefined) restartConfig.backoff_ms = backoffMs; + if (maxAttempts !== undefined) restartConfig.maxAttempts = maxAttempts; + if (backoffMs !== undefined) restartConfig.backoffMs = backoffMs; config.restart = restartConfig; } payload.config = config; return payload; } -export function buildWorkspaceRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildWorkspaceRequest( + input: GraphEntityUpsertInput, + existing?: GraphEntitySummary, +): TeamWorkspaceConfigurationCreateRequest { const configRecord = input.config as Record; - const payload: Record = { + const payload: TeamWorkspaceConfigurationCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, @@ -1066,40 +818,43 @@ export function buildWorkspaceRequest(input: GraphEntityUpsertInput, existing?: const env = mapEnvListForTeam(configRecord.env); if (env.length > 0) config.env = env; const initialScript = readOptionalString(configRecord.initialScript); - if (initialScript !== undefined) config.initial_script = initialScript; - if (configRecord.cpu_limit !== undefined) config.cpu_limit = configRecord.cpu_limit; - if (configRecord.memory_limit !== undefined) config.memory_limit = configRecord.memory_limit; - const platform = mapWorkspacePlatformToTeam(configRecord.platform); + if (initialScript !== undefined) config.initialScript = initialScript; + if (configRecord.cpu_limit !== undefined) config.cpuLimit = configRecord.cpu_limit; + if (configRecord.memory_limit !== undefined) config.memoryLimit = configRecord.memory_limit; + const platform = readWorkspacePlatform(configRecord.platform); if (platform) config.platform = platform; - const enableDinD = readBoolean(configRecord.enableDinD ?? configRecord.enable_dind ?? configRecord.enableDind); - if (enableDinD !== undefined) config.enable_dind = enableDinD; + const enableDinD = readBoolean(configRecord.enableDinD); + if (enableDinD !== undefined) config.enableDinD = enableDinD; const ttlSeconds = readNumber(configRecord.ttlSeconds); - if (ttlSeconds !== undefined) config.ttl_seconds = ttlSeconds; + if (ttlSeconds !== undefined) config.ttlSeconds = ttlSeconds; if (isRecord(configRecord.nix)) config.nix = configRecord.nix; if (isRecord(configRecord.volumes)) { const volumeConfig: Record = {}; const enabled = readBoolean(configRecord.volumes.enabled); if (enabled !== undefined) volumeConfig.enabled = enabled; - const mountPath = readOptionalString(configRecord.volumes.mountPath ?? configRecord.volumes.mount_path); - if (mountPath !== undefined) volumeConfig.mount_path = mountPath; + const mountPath = readOptionalString(configRecord.volumes.mountPath); + if (mountPath !== undefined) volumeConfig.mountPath = mountPath; config.volumes = volumeConfig; } payload.config = config; return payload; } -export function buildMemoryBucketRequest(input: GraphEntityUpsertInput, existing?: GraphEntitySummary): Record { +export function buildMemoryBucketRequest( + input: GraphEntityUpsertInput, + existing?: GraphEntitySummary, +): TeamMemoryBucketCreateRequest { const configRecord = input.config as Record; - const payload: Record = { + const payload: TeamMemoryBucketCreateRequest = { title: input.title, description: existing?.description ?? '', config: {}, }; const config: Record = {}; - const scope = mapMemoryScopeToTeam(configRecord.scope); + const scope = readMemoryScope(configRecord.scope); if (scope) config.scope = scope; - const collectionPrefix = readOptionalString(configRecord.collectionPrefix ?? configRecord.collection_prefix); - if (collectionPrefix !== undefined) config.collection_prefix = collectionPrefix; + const collectionPrefix = readOptionalString(configRecord.collectionPrefix); + if (collectionPrefix !== undefined) config.collectionPrefix = collectionPrefix; payload.config = config; return payload; } diff --git a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts index 868ceb9ab..0921ade8f 100644 --- a/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts +++ b/packages/platform-ui/src/features/entities/hooks/useTeamEntities.ts @@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ApiError } from '@/api/http'; import * as teamApi from '@/api/modules/teamApi'; import { TEAM_QUERY_KEYS, useTeamAgents, useTeamAttachments, useTeamMemoryBuckets, useTeamMcpServers, useTeamTools, useTeamWorkspaceConfigurations } from '@/api/hooks/team'; -import type { TeamAttachment } from '@/api/types/team'; import { useTemplates } from '@/lib/graph/hooks'; import { notifyError, notifySuccess } from '@/lib/notify'; import { @@ -27,21 +26,6 @@ function extractErrorMessage(error: unknown): string { return 'Request failed'; } -function readAttachmentId(attachment: TeamAttachment): string | undefined { - if (attachment.id && attachment.id.trim().length > 0) return attachment.id; - if (attachment.meta?.id && attachment.meta.id.trim().length > 0) return attachment.meta.id; - return undefined; -} - -function readAttachmentSideId(attachment: TeamAttachment, key: 'source' | 'target'): string | undefined { - const record = attachment as Record; - const direct = record[`${key}Id`]; - if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim(); - const snake = record[`${key}_id`]; - if (typeof snake === 'string' && snake.trim().length > 0) return snake.trim(); - return undefined; -} - export function useTeamEntities() { const qc = useQueryClient(); const templatesQuery = useTemplates(); @@ -87,8 +71,8 @@ export function useTeamEntities() { if (!relations) return; const current = await ensureAttachments(); const relevant = current.filter((attachment) => { - const sourceId = readAttachmentSideId(attachment, 'source'); - const targetId = readAttachmentSideId(attachment, 'target'); + const sourceId = attachment.sourceId; + const targetId = attachment.targetId; return sourceId === entityId || targetId === entityId; }); const desired = buildAttachmentInputsFromRelations(relations, entityId); @@ -96,9 +80,7 @@ export function useTeamEntities() { if (remove.length > 0) { await Promise.all( remove.map(async (attachment) => { - const id = readAttachmentId(attachment); - if (!id) return; - await teamApi.deleteAttachment(id); + await teamApi.deleteAttachment(attachment.id); }), ); } @@ -107,8 +89,6 @@ export function useTeamEntities() { create.map((attachment) => teamApi.createAttachment({ kind: attachment.kind, - source_id: attachment.sourceId, - target_id: attachment.targetId, sourceId: attachment.sourceId, targetId: attachment.targetId, }), @@ -124,10 +104,7 @@ export function useTeamEntities() { switch (input.entityKind) { case 'agent': { const created = await teamApi.createAgent(buildAgentRequest(input)); - const id = created.id ?? created.meta?.id; - if (typeof id === 'string' && id.length > 0) { - await syncAttachments(id, input.relations); - } + await syncAttachments(created.id, input.relations); return created; } case 'tool': { @@ -136,10 +113,7 @@ export function useTeamEntities() { } case 'mcp': { const created = await teamApi.createMcpServer(buildMcpServerRequest(input)); - const id = created.id ?? created.meta?.id; - if (typeof id === 'string' && id.length > 0) { - await syncAttachments(id, input.relations); - } + await syncAttachments(created.id, input.relations); return created; } case 'workspace': { @@ -168,27 +142,27 @@ export function useTeamEntities() { switch (input.entityKind) { case 'agent': { const payload = buildAgentRequest(input, existing); - const updated = await teamApi.updateAgent(input.id, { id: input.id, ...payload }); + const updated = await teamApi.updateAgent(input.id, payload); await syncAttachments(input.id, input.relations); return updated; } case 'tool': { const payload = buildToolRequest(input, existing); - return teamApi.updateTool(input.id, { id: input.id, ...payload }); + return teamApi.updateTool(input.id, payload); } case 'mcp': { const payload = buildMcpServerRequest(input, existing); - const updated = await teamApi.updateMcpServer(input.id, { id: input.id, ...payload }); + const updated = await teamApi.updateMcpServer(input.id, payload); await syncAttachments(input.id, input.relations); return updated; } case 'workspace': { const payload = buildWorkspaceRequest(input, existing); - return teamApi.updateWorkspaceConfiguration(input.id, { id: input.id, ...payload }); + return teamApi.updateWorkspaceConfiguration(input.id, payload); } case 'memory': { const payload = buildMemoryBucketRequest(input, existing); - return teamApi.updateMemoryBucket(input.id, { id: input.id, ...payload }); + return teamApi.updateMemoryBucket(input.id, payload); } default: throw new Error(`Unsupported entity kind: ${input.entityKind}`); diff --git a/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx b/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx index 906d7dbd7..8b656887e 100644 --- a/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx +++ b/packages/platform-ui/src/pages/__tests__/entities-list-page.test.tsx @@ -70,11 +70,16 @@ function primeTeamHandlers() { items: [ { id: 'agent-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', title: 'Core Agent', description: 'Primary responder', config: { model: 'gpt-4' }, }, ], + page: 1, + perPage: 50, + total: 1, }), ), http.get(abs('/apiv2/team/v1/tools'), () => @@ -82,12 +87,17 @@ function primeTeamHandlers() { items: [ { id: 'tool-1', - type: 'TOOL_TYPE_MANAGE', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + type: 'manage', name: 'manage_team', description: 'Manage tool', config: { name: 'manage_team' }, }, ], + page: 1, + perPage: 50, + total: 1, }), ), http.get(abs('/apiv2/team/v1/mcp-servers'), () => @@ -95,11 +105,16 @@ function primeTeamHandlers() { items: [ { id: 'mcp-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', title: 'Filesystem MCP', description: 'Local MCP', config: { namespace: 'fs', command: 'fs' }, }, ], + page: 1, + perPage: 50, + total: 1, }), ), http.get(abs('/apiv2/team/v1/workspace-configurations'), () => @@ -107,11 +122,16 @@ function primeTeamHandlers() { items: [ { id: 'workspace-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', title: 'Worker Pool', description: 'Default workspace', config: { image: 'docker.io/library/node:18' }, }, ], + page: 1, + perPage: 50, + total: 1, }), ), http.get(abs('/apiv2/team/v1/memory-buckets'), () => @@ -119,14 +139,26 @@ function primeTeamHandlers() { items: [ { id: 'memory-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', title: 'Global Memory', description: 'Shared', - config: { scope: 'MEMORY_BUCKET_SCOPE_GLOBAL' }, + config: { scope: 'global' }, }, ], + page: 1, + perPage: 50, + total: 1, + }), + ), + http.get(abs('/apiv2/team/v1/attachments'), () => + HttpResponse.json({ + items: [], + page: 1, + perPage: 50, + total: 0, }), ), - http.get(abs('/apiv2/team/v1/attachments'), () => HttpResponse.json({ items: [] })), ); } From b94bf68015e956bc92e23eac65b6f2716cadb3f7 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sun, 15 Mar 2026 00:34:38 +0000 Subject: [PATCH 33/43] refactor(graph): switch to Teams persistence --- docs/README.md | 8 +- docs/contributing/style_guides.md | 2 +- docs/graph/fs-store.md | 4 + docs/graph/status-updates.md | 10 +- docs/product-spec.md | 9 +- docs/tools/send_message.md | 4 +- packages/docker-runner/eslint.config.js | 64 ++ packages/platform-server/README.md | 26 +- ...ntainers.delete.docker.integration.test.ts | 1 + ...iners.fullstack.docker.integration.test.ts | 2 +- .../__tests__/fsGraph.repository.yaml.test.ts | 107 ---- .../graph.fs.persistence.integration.test.ts | 381 ------------ .../__tests__/helpers/teamsGrpc.stub.ts | 11 +- .../__tests__/hybridGraph.repository.test.ts | 57 -- .../__tests__/nodes.module.di.smoke.test.ts | 35 +- .../nodes/slack-trigger.node.spec.ts | 16 - .../__tests__/routes.variables.test.ts | 63 +- .../__tests__/slack.config.schemas.test.ts | 35 -- .../slack.pr.trigger.lifecycle.test.ts | 61 -- .../slack.threading.integration.test.ts | 194 ------ .../__tests__/slack.trigger.events.test.ts | 469 --------------- .../tools.send_message.integration.test.ts | 118 +--- .../migration.sql | 27 + packages/platform-server/prisma/schema.prisma | 18 + .../src/graph-domain/graph-domain.module.ts | 15 +- .../controllers/graphVariables.controller.ts | 9 +- .../src/graph/fsGraph.repository.ts | 560 ------------------ .../src/graph/graphSchema.validator.ts | 39 -- .../src/graph/hybridGraph.repository.ts | 125 ---- packages/platform-server/src/graph/index.ts | 3 +- .../graph/services/graphVariables.service.ts | 82 +-- .../src/graph/teamsGraph.repository.ts | 87 +++ .../src/graph/teamsGraph.source.ts | 28 +- .../platform-server/src/graph/yaml.util.ts | 14 - .../src/infra/container/runnerGrpc.client.ts | 49 +- .../platform-server/src/nodes/nodes.module.ts | 3 - .../nodes/slackTrigger/slackTrigger.node.ts | 351 ----------- packages/platform-server/src/templates.ts | 10 - .../SlackTriggerConfigView.test.tsx | 197 ------ packages/platform-ui/src/api/modules/graph.ts | 6 +- .../src/components/agents/GraphLayout.tsx | 55 +- .../__tests__/EntityUpsertForm.test.tsx | 3 - .../NodePropertiesSidebar.trigger.test.tsx | 148 ----- .../src/features/graph/services/api.ts | 3 +- .../variables/__tests__/hooks.test.tsx | 9 - .../src/features/variables/hooks.ts | 10 +- .../src/lib/graph/__tests__/normalize.test.ts | 8 +- packages/platform-ui/src/lib/graph/hooks.ts | 5 +- 48 files changed, 407 insertions(+), 3134 deletions(-) create mode 100644 packages/docker-runner/eslint.config.js delete mode 100644 packages/platform-server/__tests__/fsGraph.repository.yaml.test.ts delete mode 100644 packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts delete mode 100644 packages/platform-server/__tests__/hybridGraph.repository.test.ts delete mode 100644 packages/platform-server/__tests__/nodes/slack-trigger.node.spec.ts delete mode 100644 packages/platform-server/__tests__/slack.config.schemas.test.ts delete mode 100644 packages/platform-server/__tests__/slack.pr.trigger.lifecycle.test.ts delete mode 100644 packages/platform-server/__tests__/slack.threading.integration.test.ts delete mode 100644 packages/platform-server/__tests__/slack.trigger.events.test.ts create mode 100644 packages/platform-server/prisma/migrations/20251222120000_graph_state_tables/migration.sql delete mode 100644 packages/platform-server/src/graph/fsGraph.repository.ts delete mode 100644 packages/platform-server/src/graph/graphSchema.validator.ts delete mode 100644 packages/platform-server/src/graph/hybridGraph.repository.ts create mode 100644 packages/platform-server/src/graph/teamsGraph.repository.ts delete mode 100644 packages/platform-server/src/graph/yaml.util.ts delete mode 100644 packages/platform-server/src/nodes/slackTrigger/slackTrigger.node.ts delete mode 100644 packages/platform-ui/__tests__/components/configViews/SlackTriggerConfigView.test.tsx delete mode 100644 packages/platform-ui/src/components/nodeProperties/__tests__/NodePropertiesSidebar.trigger.test.tsx diff --git a/docs/README.md b/docs/README.md index dab319309..3cd881643 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ How these docs are organized - Product specification: end-to-end features, behaviors, and operations. -- API and graph store: HTTP/Socket APIs and persistence internals. +- API and graph sources: HTTP/Socket APIs and persistence internals. - Containers and security: workspace lifecycle and secret handling. - Observability and UI: traces, spans, and the graph builder. - Contributing and ADRs: internal engineering references. @@ -11,8 +11,8 @@ Index - Product Spec: [product-spec.md](product-spec.md) - API Reference: [api/index.md](api/index.md) - Graph - - Filesystem Store: [graph/fs-store.md](graph/fs-store.md) - - Status Updates: [graph/status-updates.md](graph/status-updates.md) + - Teams graph source & status updates: [graph/status-updates.md](graph/status-updates.md) + - Legacy filesystem store (deprecated): [graph/fs-store.md](graph/fs-store.md) - Containers - Workspaces: [containers/workspaces.md](containers/workspaces.md) - Env Overlays: [config/env-overlays.md](config/env-overlays.md) @@ -36,4 +36,4 @@ Index Slack integration - For Slack-triggered flows and outbound messages, see: - Secrets and tokens: [security/vault.md](security/vault.md) - - Graph UI templates (SlackTrigger, SendSlackMessageTool): [ui/graph/README.md](ui/graph/README.md) + - Graph UI templates (SendSlackMessageTool): [ui/graph/README.md](ui/graph/README.md) diff --git a/docs/contributing/style_guides.md b/docs/contributing/style_guides.md index 5ab8ffc5d..832a4f16b 100644 --- a/docs/contributing/style_guides.md +++ b/docs/contributing/style_guides.md @@ -46,7 +46,7 @@ Our repo currently uses: - Prefer functional, pure modules. Side effects live in service classes. ### Node.js server -- Keep services injectable and stateless. IO is abstracted behind services (e.g., PrismaService). Note: Slack no longer uses a global service; Slack integration is configured per node (see SlackTrigger and SendSlackMessageTool static configs). +- Keep services injectable and stateless. IO is abstracted behind services (e.g., PrismaService). Note: Slack integration is configured per node (see SendSlackMessageTool static configs). - Configuration comes from `ConfigService` reading env. No direct `process.env` reads inside business logic. - Log with structured messages. Avoid console.log in code; use Nest's `Logger` (per-class instance). - Graceful shutdown handlers must close external connections. diff --git a/docs/graph/fs-store.md b/docs/graph/fs-store.md index 0a1b013ec..ef4f38411 100644 --- a/docs/graph/fs-store.md +++ b/docs/graph/fs-store.md @@ -1,5 +1,9 @@ # Filesystem-backed Graph Store (format: 2) +> **Deprecated:** Filesystem-backed graph persistence has been removed. The platform now sources graph +> configuration from the Teams service and `/api/graph` is read-only. This document is retained for +> legacy reference only. + Overview - Graph persistence writes directly to the filesystem under `GRAPH_REPO_PATH` (default `./data/graph`). - The layout matches the legacy git working tree: `graph.meta.yaml`, `nodes/`, `edges/`, and `variables.yaml` live at the path root. diff --git a/docs/graph/status-updates.md b/docs/graph/status-updates.md index f7ce4c99b..410051575 100644 --- a/docs/graph/status-updates.md +++ b/docs/graph/status-updates.md @@ -32,11 +32,14 @@ socket.on('node_status', (payload) => { }); Notes -- HTTP endpoints remain for actions (pause/resume, provision/deprovision) and configuration updates. +- HTTP endpoints remain for actions (pause/resume, provision/deprovision) and node state updates. - Remove any polling loops (e.g., 2s intervals) for status; rely on socket events. -Config persistence -- Graph configuration changes persist via POST /api/graph (full-graph updates). +Graph source and persistence +- Graph configuration is sourced from the Teams service; `/api/graph` is GET-only and returns the latest snapshot. +- UI edits to layout are local-only; the backend does not accept full-graph writes. +- Node state persists via `/api/graph/nodes/:nodeId/state` and is merged into the snapshot on read. +- Graph variables persist via `/api/graph/variables`; local overrides are stored separately. - The per-node dynamic-config save endpoint was removed; only the schema endpoint remains for rendering purposes. ## Template Capabilities & Static Config (Updated) @@ -56,7 +59,6 @@ Static config schemas (all templates now expose one – some are currently empty - `shellTool`: (empty object for now). - `githubCloneRepoTool`: (empty object for now). - `sendSlackMessageTool`: (empty object for now). -- `slackTrigger`: debounceMs, waitForBusy (note: presently setConfig is a no-op; values must be supplied at creation time until runtime reconfiguration is implemented). Dynamic config (currently only MCP server) becomes available after initial tool discovery; UI should check `dynamicConfigReady` before rendering its form. diff --git a/docs/product-spec.md b/docs/product-spec.md index 5d11fe11e..9542e4ac9 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -95,10 +95,10 @@ Performance and scale - Observability storage relies on Postgres; add indices on spans by nodeId, traceId, timestamps. Upgrade and migration -- Graph store now writes directly to the working tree at `GRAPH_REPO_PATH` using staged swaps; legacy git guards and migration tooling have been removed. Ensure any old `.git` directories are deleted or copied elsewhere before pointing the server at the path. +- Graph configuration is sourced from Teams service; `/api/graph` is GET-only. Node state and graph variables persist in Postgres (`GraphNodeState`, `GraphVariable`); filesystem-backed graph storage is retired. - UI dependency on change streams is retired alongside Mongo. - MCP heartbeat/backoff planned; non-breaking once added. -- See: docs/graph/fs-store.md +- See: docs/graph/fs-store.md (legacy filesystem format reference) Configuration matrix (server env vars) - Required @@ -111,9 +111,8 @@ Configuration matrix (server env vars) - Optional - LLM_PROVIDER (defaults to `litellm`; set to `openai` to bypass LiteLLM). Other values are rejected. - LiteLLM tuning: LITELLM_KEY_ALIAS (default `agents//`), LITELLM_KEY_DURATION (`30d`), LITELLM_MODELS (`all-team-models`) - - GRAPH_REPO_PATH (default ./data/graph) - - GRAPH_BRANCH (default main) - - GRAPH_AUTHOR_NAME / GRAPH_AUTHOR_EMAIL + - TEAMS_SERVICE_ADDR (Teams gRPC endpoint for graph source) + - GRAPH_REPO_PATH / GRAPH_BRANCH / GRAPH_AUTHOR_NAME / GRAPH_AUTHOR_EMAIL (legacy; ignored) - VAULT_ENABLED: true|false (default false) - VAULT_ADDR, VAULT_TOKEN - DOCKER_MIRROR_URL (default http://registry-mirror:5000) diff --git a/docs/tools/send_message.md b/docs/tools/send_message.md index 7bac10fcc..59a492ab9 100644 --- a/docs/tools/send_message.md +++ b/docs/tools/send_message.md @@ -15,12 +15,12 @@ Behavior - Requires `ctx.threadId`. Uses `ThreadTransportService.sendTextToThread` to look up the persisted `channelNodeId` for the current thread and delegate delivery to the resolved node. - When a thread has no `channelNodeId` (e.g., created in the web UI without an ingress trigger), the transport falls back to persisting the assistant reply and returns success without invoking any external adapter. -- The channel node must implement `sendToChannel(threadId, text)`; Manage, SlackTrigger, and any future channel adapters satisfy this interface. +- The channel node must implement `sendToChannel(threadId, text)`; Manage and any future channel adapters satisfy this interface. - Returns `message sent successfully` when the transport succeeds or when the persistence-only fallback is used; otherwise returns the error string produced by the transport service (e.g. `channel_node_unavailable`, `unsupported_channel_node`, or adapter-specific failures). - Validation: rejects missing thread context or empty `message` payload at the schema layer (min length 1). - Run events: tool executions triggered via `send_message` emit only the standard `tool_execution` events; no additional `invocation_message` event is appended. Notes -- Manage registers itself as the channel node for child threads when mediating worker communication. SlackTrigger (and other ingress adapters) set the channel node for user-originated threads. +- Manage registers itself as the channel node for child threads when mediating worker communication. Ingress adapters are responsible for setting the channel node for user-originated threads. - Errors are surfaced to the caller without additional formatting to simplify tool reasoning and retry behavior. diff --git a/packages/docker-runner/eslint.config.js b/packages/docker-runner/eslint.config.js new file mode 100644 index 000000000..c87bb161e --- /dev/null +++ b/packages/docker-runner/eslint.config.js @@ -0,0 +1,64 @@ +import tseslint from 'typescript-eslint'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default [ + { + ignores: ['dist/**', '**/dist/**', 'node_modules/**', 'src/proto/gen/**'], + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: ['./tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + plugins: { '@typescript-eslint': tseslint.plugin }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + 'no-empty': ['error', { allowEmptyCatch: false }], + 'max-depth': ['error', 3], + 'no-useless-catch': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + '@typescript-eslint/no-unsafe-function-type': 'error', + '@typescript-eslint/no-empty-object-type': 'error', + 'no-useless-escape': 'error', + 'prefer-const': 'error', + 'no-redeclare': 'error', + }, + }, + { + files: ['__tests__/**/*.ts', '__tests__/**/*.tsx'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: ['./tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + plugins: { '@typescript-eslint': tseslint.plugin }, + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-empty': ['error', { allowEmptyCatch: false }], + 'max-depth': ['error', 3], + 'no-useless-catch': 'error', + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + '@typescript-eslint/no-unsafe-function-type': 'error', + '@typescript-eslint/no-empty-object-type': 'error', + 'no-useless-escape': 'error', + 'prefer-const': 'error', + 'no-redeclare': 'error', + }, + }, +]; diff --git a/packages/platform-server/README.md b/packages/platform-server/README.md index 5726ee6ae..ab2737b70 100644 --- a/packages/platform-server/README.md +++ b/packages/platform-server/README.md @@ -3,17 +3,11 @@ Runtime for graph-driven agents, tool adapters, triggers, and memory. See docs for architecture. Graph persistence -- Configure via env: - - `GRAPH_REPO_PATH`: filesystem root for the working tree (default `./data/graph`). - - `GRAPH_BRANCH`: logical branch label retained for compatibility/telemetry (default `main`). - - `GRAPH_AUTHOR_NAME` / `GRAPH_AUTHOR_EMAIL`: optional metadata forwarded to audit logs. - - `GRAPH_LOCK_TIMEOUT_MS`: file-lock acquisition timeout (default `5000`). -- On startup the server ensures `GRAPH_REPO_PATH` exists with `nodes/`, `edges/`, `variables.yaml`, and `graph.meta.yaml`. There is no dataset indirection or Git requirement; `.git` directories are ignored if present. -- `/api/graph` semantics remain the same (GET to read, POST to upsert). Writes continue to use optimistic locking via the `version` field and acquire a lock file named `..graph.lock` next to `GRAPH_REPO_PATH` before writing. Each write builds a staged working tree in a sibling directory, fsyncs it, and atomically swaps it into place so readers only see committed graphs; old trees move to `.graph-backup-*` temporarily and are deleted immediately. -- Error responses: - - `409 VERSION_CONFLICT` with `{ error, current }` when the supplied version is stale. - - `409 LOCK_TIMEOUT` if the repository lock cannot be acquired within the configured timeout. - - `500 PERSIST_FAILED` when filesystem writes fail unexpectedly; the in-flight changes are rolled back to the last committed state. +- Graph configuration is sourced from the Teams service; `/api/graph` is GET-only and returns the latest snapshot. +- Configure the Teams gRPC endpoint via `TEAMS_SERVICE_ADDR`. +- Node runtime state persists in Postgres (`GraphNodeState`) and is merged into the snapshot on read. +- Graph variables persist in Postgres (`GraphVariable`) with local overrides in `VariableLocal`; manage them via `/api/graph/variables`. +- Legacy `GRAPH_*` env vars remain accepted for compatibility but are ignored. ## Networking and cache @@ -88,11 +82,6 @@ At runtime the node calls `EnvService.resolveProviderEnv`, which delegates to `R The resolved overlay is merged with any base environment and forwarded to Docker exec sessions for both discovery and tool calls, ensuring MCP servers receive the same env regardless of execution path. -Storage layout (format: 2) -- Repository root: `` contains `graph.meta.yaml`, `variables.yaml`, `nodes/`, and `edges/`. -- Filenames remain `encodeURIComponent(id)`; edge ids are deterministic `-__-`. -- Writes stage a complete working tree in a sibling `.graph-staging-*` directory, atomically swap it into ``, and delete the previous tree (which briefly lives at `.graph-backup-*`). The advisory lock file lives beside the repo path as `..graph.lock`, and `.git` directories are moved back into place after each swap if present. - Enabling Memory - Default connector config: placement=after_system, content=tree, maxChars=4000. - To wire memory into an agent's CallModel at runtime, add a `memoryNode` and connect its `$self` source port to the agent's `callModel`/`setMemoryConnector` target port (or use template API to create a connector). @@ -197,9 +186,8 @@ Set the flag while running targeted tests or during local debugging to immediate - `pnpm --filter @agyn/platform-server prisma generate` - For production deployments, apply migrations with `prisma migrate deploy` as part of your release process. -Messaging (Slack-only v1) +Messaging (channel adapters) -- `send_message` routes replies to Slack using `Thread.channel` (descriptor written by `SlackTrigger`) when a channel node is registered, and falls back to persisting the reply when no channel node exists (e.g., web-created threads). +- `send_message` routes replies using the `Thread.channel` descriptor when a channel node is registered, and falls back to persisting the reply when no channel node exists (e.g., web-created threads). - Runs triggered via `send_message` emit only `tool_execution` run events; no additional `invocation_message` entry is created for the persisted transport message. -- SlackTrigger requires bot_token in node config; token is resolved during provision; no global Slack config or tokens. - No other adapters are supported in v1; attachments/ephemeral not supported. diff --git a/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts b/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts index 2a0a65eb0..cafaa67af 100644 --- a/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts +++ b/packages/platform-server/__tests__/containers.delete.docker.integration.test.ts @@ -24,6 +24,7 @@ import { runnerSecretMissing, socketMissing, startDockerRunner, + startDockerRunnerProcess, startPostgres, runPrismaMigrations, type RunnerHandle, diff --git a/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts b/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts index 6a49a8e74..cef53f487 100644 --- a/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts +++ b/packages/platform-server/__tests__/containers.fullstack.docker.integration.test.ts @@ -25,7 +25,7 @@ import { runnerAddressMissing, runnerSecretMissing, socketMissing, - startDockerRunner, + startDockerRunnerProcess, startPostgres, runPrismaMigrations, waitFor, diff --git a/packages/platform-server/__tests__/fsGraph.repository.yaml.test.ts b/packages/platform-server/__tests__/fsGraph.repository.yaml.test.ts deleted file mode 100644 index 0361edf05..000000000 --- a/packages/platform-server/__tests__/fsGraph.repository.yaml.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { promises as fs } from 'fs'; -import os from 'os'; -import path from 'path'; -import { FsGraphRepository } from '../src/graph/fsGraph.repository'; -import type { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { ConfigService } from '../src/core/services/config.service'; - -const schema = [ - { name: 'trigger', title: 'Trigger', kind: 'trigger', sourcePorts: ['out'], targetPorts: [] }, - { name: 'agent', title: 'Agent', kind: 'agent', sourcePorts: [], targetPorts: ['in'] }, -] as const; - -const defaultGraph = { - name: 'main', - version: 0, - nodes: [ - { id: 'trigger', template: 'trigger', position: { x: 0, y: 0 } }, - { id: 'agent', template: 'agent', position: { x: 1, y: 1 } }, - ], - edges: [{ source: 'trigger', sourceHandle: 'out', target: 'agent', targetHandle: 'in' }], - variables: [{ key: 'env', value: 'prod' }], -}; - -async function pathExists(p: string): Promise { - try { - await fs.stat(p); - return true; - } catch { - return false; - } -} - -function createTemplateRegistry(): TemplateRegistry { - return { - toSchema: vi.fn().mockResolvedValue(schema), - } as unknown as TemplateRegistry; -} - -function createConfig(graphRepoPath: string): ConfigService { - const base = { - graphRepoPath, - graphLockTimeoutMs: 1000, - } as const; - return base as unknown as ConfigService; -} - -describe('FsGraphRepository YAML storage', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-yaml-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - function repoPath(...segments: string[]): string { - return path.join(tempDir, ...segments); - } - - it('writes YAML files by default', async () => { - const repo = new FsGraphRepository(createConfig(tempDir), createTemplateRegistry()); - - await repo.initIfNeeded(); - await repo.upsert(defaultGraph, undefined); - - const metaYaml = repoPath('graph.meta.yaml'); - const metaJson = repoPath('graph.meta.json'); - const nodeYaml = repoPath('nodes', 'trigger.yaml'); - const nodeJson = repoPath('nodes', 'trigger.json'); - const edgeYaml = repoPath('edges', `${encodeURIComponent('trigger-out__agent-in')}.yaml`); - const edgeJson = repoPath('edges', `${encodeURIComponent('trigger-out__agent-in')}.json`); - const varsYaml = repoPath('variables.yaml'); - const varsJson = repoPath('variables.json'); - - expect(await pathExists(metaYaml)).toBe(true); - expect(await pathExists(metaJson)).toBe(false); - expect(await pathExists(nodeYaml)).toBe(true); - expect(await pathExists(nodeJson)).toBe(false); - expect(await pathExists(edgeYaml)).toBe(true); - expect(await pathExists(edgeJson)).toBe(false); - expect(await pathExists(varsYaml)).toBe(true); - expect(await pathExists(varsJson)).toBe(false); - - const stored = await repo.get('main'); - expect(stored?.nodes).toHaveLength(2); - expect(stored?.edges).toHaveLength(1); - expect(stored?.variables?.[0]).toEqual({ key: 'env', value: 'prod' }); - }); - - it('ignores legacy JSON files in working tree', async () => { - const repo = new FsGraphRepository(createConfig(tempDir), createTemplateRegistry()); - - await repo.initIfNeeded(); - await repo.upsert(defaultGraph, undefined); - - await fs.writeFile(repoPath('graph.meta.json'), '{ invalid json', 'utf8'); - await fs.writeFile(repoPath('nodes', 'trigger.json'), '{ invalid json', 'utf8'); - - const stored = await repo.get('main'); - expect(stored?.version).toBeGreaterThan(0); - expect(stored?.nodes).toHaveLength(2); - expect(await pathExists(repoPath('graph.meta.yaml'))).toBe(true); - }); -}); diff --git a/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts b/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts deleted file mode 100644 index 50eca1674..000000000 --- a/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { promises as fs } from 'fs'; -import os from 'os'; -import path from 'path'; -import { FsGraphRepository } from '../src/graph/fsGraph.repository'; -import { ConfigService, configSchema } from '../src/core/services/config.service'; -import { runnerConfigDefaults } from './helpers/config'; -import type { TemplateRegistry } from '../src/graph-core/templateRegistry'; - -const schema = [ - { name: 'trigger', title: 'Trigger', kind: 'trigger', sourcePorts: ['out'], targetPorts: [] }, -] as const; - -const templateRegistryStub: TemplateRegistry = { - register: () => templateRegistryStub, - getClass: () => undefined, - getMeta: () => undefined, - toSchema: async () => schema as unknown as typeof schema, -} as unknown as TemplateRegistry; - -const baseConfigEnv = { - llmProvider: 'openai', - githubAppId: 'app', - githubAppPrivateKey: 'key', - githubInstallationId: 'inst', - githubToken: 'token', - agentsDatabaseUrl: 'postgres://localhost:5432/agents', - litellmBaseUrl: 'http://localhost:4000', - litellmMasterKey: 'sk-test', - litellmKeyAlias: 'agents/test/fs', - litellmKeyDuration: '30d', - litellmModels: ['all-team-models'], - teamsServiceAddr: 'teams:9090', - dockerMirrorUrl: 'http://registry-mirror:5000', - nixAllowedChannels: 'nixpkgs-unstable', - ...runnerConfigDefaults, -}; - -describe('FsGraphRepository filesystem persistence', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-fs-')); - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - function repoPath(...segments: string[]): string { - return path.join(tempDir, ...segments); - } - - it('initializes, upserts, and reads graph data without git involvement', async () => { - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - - await repo.initIfNeeded(); - const saved = await repo.upsert( - { - name: 'main', - version: 0, - nodes: [{ id: 'start', template: 'trigger' }], - edges: [], - }, - undefined, - ); - - expect(saved.version).toBe(1); - const loaded = await repo.get('main'); - expect(loaded?.nodes.map((n) => n.id)).toEqual(['start']); - expect(await pathExists(path.join(tempDir, '.git'))).toBe(false); - }); - - it('does not create recovery directories when graph changes', async () => { - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - await repo.initIfNeeded(); - - await repo.upsert( - { - name: 'main', - version: 0, - nodes: [{ id: 'start', template: 'trigger' }], - edges: [], - }, - undefined, - ); - - await repo.upsert( - { - name: 'main', - version: 1, - nodes: [ - { id: 'start', template: 'trigger' }, - { id: 'branch', template: 'trigger' }, - ], - edges: [], - }, - undefined, - ); - - expect(await pathExists(repoPath('snapshots'))).toBe(false); - expect(await pathExists(repoPath('journal'))).toBe(false); - expect(await pathExists(repoPath('journal.ndjson'))).toBe(false); - const artifacts = await listTempArtifacts(tempDir); - expect(artifacts.staging).toHaveLength(0); - expect(artifacts.backups).toHaveLength(0); - }); - - it('keeps the lock active throughout staged swaps', async () => { - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - graphLockTimeoutMs: 100, - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - await repo.initIfNeeded(); - - await repo.upsert( - { - name: 'main', - version: 0, - nodes: [{ id: 'start', template: 'trigger' }], - edges: [], - }, - undefined, - ); - - const originalSwap = (repo as any).swapWorkingTree.bind(repo); - let swapStarted!: () => void; - const swapReady = new Promise((resolve) => { - swapStarted = resolve; - }); - const swapSpy = vi.spyOn(repo as any, 'swapWorkingTree').mockImplementation(async (...args: unknown[]) => { - swapStarted(); - await new Promise((resolve) => setTimeout(resolve, 200)); - return originalSwap(...args); - }); - - const firstWrite = repo.upsert( - { - name: 'main', - version: 1, - nodes: [ - { id: 'start', template: 'trigger' }, - { id: 'next', template: 'trigger' }, - ], - edges: [], - }, - undefined, - ); - - await swapReady; - - await expect( - repo.upsert( - { - name: 'main', - version: 1, - nodes: [ - { id: 'start', template: 'trigger' }, - { id: 'branch', template: 'trigger' }, - ], - edges: [], - }, - undefined, - ), - ).rejects.toMatchObject({ code: 'LOCK_TIMEOUT' }); - - swapSpy.mockRestore(); - await firstWrite; - }); - - it('restores the previous tree if a staged swap fails', async () => { - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - await repo.initIfNeeded(); - - await repo.upsert( - { - name: 'main', - version: 0, - nodes: [{ id: 'start', template: 'trigger' }], - edges: [], - }, - undefined, - ); - - const realRename = fs.rename; - let shouldFail = true; - const renameSpy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { - if (shouldFail && typeof to === 'string' && to === repoPath()) { - shouldFail = false; - throw new Error('rename-fail'); - } - return realRename.call(fs, from, to); - }); - - await expect( - repo.upsert( - { - name: 'main', - version: 1, - nodes: [ - { id: 'start', template: 'trigger' }, - { id: 'next', template: 'trigger' }, - ], - edges: [], - }, - undefined, - ), - ).rejects.toMatchObject({ code: 'PERSIST_FAILED' }); - - renameSpy.mockRestore(); - - const loaded = await repo.get('main'); - expect(loaded?.version).toBe(1); - expect(loaded?.nodes).toHaveLength(1); - const artifacts = await listTempArtifacts(tempDir); - expect(artifacts.staging).toHaveLength(0); - expect(artifacts.backups).toHaveLength(0); - }); - - it('repairs orphaned staging artifacts on startup', async () => { - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - await repo.initIfNeeded(); - - await repo.upsert( - { - name: 'main', - version: 0, - nodes: [{ id: 'start', template: 'trigger' }], - edges: [], - }, - undefined, - ); - - await repo.upsert( - { - name: 'main', - version: 1, - nodes: [ - { id: 'start', template: 'trigger' }, - { id: 'branch', template: 'trigger' }, - ], - edges: [], - }, - undefined, - ); - - const parent = path.dirname(tempDir); - const baseName = sanitizeBaseName(tempDir); - const orphanBackup = path.join(parent, `.graph-backup-${baseName}-${Date.now().toString(36)}`); - const orphanStaging = path.join(parent, `.graph-staging-${baseName}-${Date.now().toString(36)}`); - await fs.rename(tempDir, orphanBackup); - await fs.mkdir(orphanStaging, { recursive: true }); - - const repoAfterCrash = new FsGraphRepository(cfg, templateRegistryStub); - await repoAfterCrash.initIfNeeded(); - const loaded = await repoAfterCrash.get('main'); - expect(loaded?.version).toBe(2); - expect(loaded?.nodes).toHaveLength(2); - - const artifacts = await listTempArtifacts(tempDir); - expect(artifacts.staging).toHaveLength(0); - expect(artifacts.backups).toHaveLength(0); - }); - - it('scopes artifact cleanup to this repo only', async () => { - const baseName = sanitizeBaseName(tempDir); - const parent = path.dirname(tempDir); - const oursStaging = path.join(parent, `.graph-staging-${baseName}-dangling`); - const oursBackup = path.join(parent, `.graph-backup-${baseName}-dangling`); - const otherStaging = path.join(parent, `.graph-staging-otherrepo-dangling`); - const otherBackup = path.join(parent, `.graph-backup-otherrepo-dangling`); - await fs.mkdir(oursStaging, { recursive: true }); - await fs.mkdir(oursBackup, { recursive: true }); - await fs.mkdir(otherStaging, { recursive: true }); - await fs.mkdir(otherBackup, { recursive: true }); - - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - await repo.initIfNeeded(); - - expect(await pathExists(oursStaging)).toBe(false); - expect(await pathExists(oursBackup)).toBe(false); - expect(await pathExists(otherStaging)).toBe(true); - expect(await pathExists(otherBackup)).toBe(true); - - await fs.rm(otherStaging, { recursive: true, force: true }); - await fs.rm(otherBackup, { recursive: true, force: true }); - }); - - it('ignores leftover git directories in the repo path', async () => { - await fs.mkdir(path.join(tempDir, '.git', 'objects'), { recursive: true }); - await fs.writeFile(path.join(tempDir, '.git', 'HEAD'), 'ref: refs/heads/main'); - - const cfg = new ConfigService().init( - configSchema.parse({ - ...baseConfigEnv, - graphRepoPath: tempDir, - graphBranch: 'feature/x', - }), - ); - const repo = new FsGraphRepository(cfg, templateRegistryStub); - - await repo.initIfNeeded(); - await repo.upsert( - { - name: 'main', - version: 0, - nodes: [{ id: 'start', template: 'trigger' }], - edges: [], - }, - undefined, - ); - - const loaded = await repo.get('main'); - expect(loaded?.nodes).toHaveLength(1); - expect(await pathExists(path.join(tempDir, '.git', 'HEAD'))).toBe(true); - }); -}); - -async function pathExists(p: string): Promise { - try { - await fs.stat(p); - return true; - } catch { - return false; - } -} - -async function listTempArtifacts(root: string): Promise<{ staging: string[]; backups: string[] }> { - const parent = path.dirname(root); - const baseName = sanitizeBaseName(root); - const stagingPrefix = `.graph-staging-${baseName}-`; - const backupPrefix = `.graph-backup-${baseName}-`; - try { - const entries = await fs.readdir(parent); - return { - staging: entries.filter((name) => name.startsWith(stagingPrefix)), - backups: entries.filter((name) => name.startsWith(backupPrefix)), - }; - } catch { - return { staging: [], backups: [] }; - } -} - -function sanitizeBaseName(root: string): string { - const base = path.basename(root).replace(/[^a-zA-Z0-9.-]/g, '_'); - return base.length ? base : 'graph'; -} diff --git a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts index 0d01cb99f..229fcb955 100644 --- a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts +++ b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts @@ -6,6 +6,7 @@ import type { Tool, WorkspaceConfiguration, } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb'; +import { ToolType } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb'; import type { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; type TeamsClientStubOptions = { @@ -43,12 +44,14 @@ export const createTeamsClientStub = (options?: TeamsClientStubOptions): TeamsGr const listAgents = options?.listAgents ?? (async (request: { page: number; perPage: number }) => paginate(agents, request.page, request.perPage)); const listTools = options?.listTools ?? - (async (request: { page: number; perPage: number; type?: Tool['type'] }) => - paginate( - request.type === undefined ? tools : tools.filter((tool) => tool.type === request.type), + (async (request: { page: number; perPage: number; type?: Tool['type'] }) => { + const shouldFilter = typeof request.type === 'number' && request.type !== ToolType.UNSPECIFIED; + return paginate( + shouldFilter ? tools.filter((tool) => tool.type === request.type) : tools, request.page, request.perPage, - )); + ); + }); const listMcpServers = options?.listMcpServers ?? (async (request: { page: number; perPage: number }) => paginate(mcps, request.page, request.perPage)); const listWorkspaceConfigurations = options?.listWorkspaceConfigurations ?? (async (request: { page: number; perPage: number }) => paginate(workspaces, request.page, request.perPage)); diff --git a/packages/platform-server/__tests__/hybridGraph.repository.test.ts b/packages/platform-server/__tests__/hybridGraph.repository.test.ts deleted file mode 100644 index 0091c7ca9..000000000 --- a/packages/platform-server/__tests__/hybridGraph.repository.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { PersistedGraph } from '../src/shared/types/graph.types'; -import type { TeamsGraphSnapshot } from '../src/graph/teamsGraph.source'; -import { HybridGraphRepository } from '../src/graph/hybridGraph.repository'; -import { edgeKey } from '../src/graph/graph.utils'; - -describe('HybridGraphRepository mergeGraphs', () => { - it('preserves FS-only nodes and merges Teams-managed nodes', () => { - const repo = new HybridGraphRepository({} as any, {} as any); - const base: PersistedGraph = { - name: 'main', - version: 1, - updatedAt: '2024-01-01T00:00:00.000Z', - nodes: [ - { id: 'trigger-1', template: 'trigger', config: { label: 'start' } }, - { id: 'agent-1', template: 'agent', config: { title: 'FS Agent' }, state: { status: 'idle' }, position: { x: 5, y: 7 } }, - { id: 'workspace-orphan', template: 'workspace', config: { title: 'FS Workspace' } }, - ], - edges: [ - { source: 'trigger-1', sourceHandle: 'out', target: 'agent-1', targetHandle: 'in' }, - { source: 'workspace-orphan', sourceHandle: 'out', target: 'agent-1', targetHandle: 'in' }, - ], - variables: [], - }; - const teamsGraph: TeamsGraphSnapshot = { - nodes: [ - { id: 'agent-1', template: 'agent', config: { title: 'Teams Agent' } }, - { id: 'workspace-1', template: 'workspace', config: { title: 'Teams Workspace' } }, - ], - edges: [ - { source: 'workspace-1', sourceHandle: '$self', target: 'agent-1', targetHandle: 'tools' }, - ], - }; - - const merged = (repo as any).mergeGraphs(base, teamsGraph) as PersistedGraph; - const nodesById = new Map(merged.nodes.map((node) => [node.id, node])); - - expect(nodesById.has('workspace-orphan')).toBe(false); - expect(nodesById.get('trigger-1')).toMatchObject({ template: 'trigger', config: { label: 'start' } }); - expect(nodesById.get('workspace-1')).toMatchObject({ template: 'workspace', config: { title: 'Teams Workspace' } }); - expect(nodesById.get('agent-1')).toMatchObject({ - template: 'agent', - config: { title: 'Teams Agent' }, - state: { status: 'idle' }, - position: { x: 5, y: 7 }, - }); - - const edgeKeys = merged.edges.map(edgeKey); - const expectedEdges = [ - edgeKey({ source: 'trigger-1', sourceHandle: 'out', target: 'agent-1', targetHandle: 'in' }), - edgeKey({ source: 'workspace-1', sourceHandle: '$self', target: 'agent-1', targetHandle: 'tools' }), - ]; - - expect(edgeKeys).toHaveLength(expectedEdges.length); - expect(edgeKeys).toEqual(expect.arrayContaining(expectedEdges)); - }); -}); diff --git a/packages/platform-server/__tests__/nodes.module.di.smoke.test.ts b/packages/platform-server/__tests__/nodes.module.di.smoke.test.ts index 49bace940..dfdb86544 100644 --- a/packages/platform-server/__tests__/nodes.module.di.smoke.test.ts +++ b/packages/platform-server/__tests__/nodes.module.di.smoke.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { SlackTrigger } from '../src/nodes/slackTrigger/slackTrigger.node'; import { RemindMeNode } from '../src/nodes/tools/remind_me/remind_me.node'; -import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import { PrismaService } from '../src/core/services/prisma.service'; -import { SlackAdapter } from '../src/messaging/slack/slack.adapter'; import { EventsBusService } from '../src/events/events-bus.service'; -import { createReferenceResolverStub } from './helpers/reference-resolver.stub'; process.env.LLM_PROVIDER = process.env.LLM_PROVIDER || 'litellm'; process.env.AGENTS_DATABASE_URL = process.env.AGENTS_DATABASE_URL || 'postgres://localhost:5432/test'; @@ -22,16 +18,6 @@ const makeStub = >(overrides: T): T => }, }); -const slackAdapterStub = makeStub({ - sendText: vi.fn(), -}); - -const persistenceStub = makeStub({ - getOrCreateThreadByAlias: vi.fn().mockResolvedValue('thread-123'), - updateThreadChannelDescriptor: vi.fn().mockResolvedValue(undefined), - ensureAssignedAgent: vi.fn().mockResolvedValue(undefined), -}); - const prismaClientStub = makeStub({ $transaction: vi.fn(async (cb: (tx: Record) => Promise) => cb({})), }); @@ -53,15 +39,6 @@ const eventsBusStub = makeStub({ emitReminderCount: vi.fn(), }); -const runtimeStub = makeStub({ - getOutboundNodeIds: vi.fn(() => []), - getNodes: vi.fn(() => []), -}); - -const templateRegistryStub = makeStub({ - getMeta: vi.fn(() => undefined), -}); - if (!shouldRunDbTests) { describe.skip('NodesModule DI smoke test', () => { it('skipped because RUN_DB_TESTS is not true', () => { @@ -70,17 +47,7 @@ if (!shouldRunDbTests) { }); } else { describe('NodesModule DI smoke test', () => { - it('constructs SlackTrigger and RemindMeNode with stubs', () => { - const slackTrigger = new SlackTrigger( - createReferenceResolverStub().stub, - persistenceStub as unknown as AgentsPersistenceService, - prismaStub as unknown as PrismaService, - slackAdapterStub as unknown as SlackAdapter, - runtimeStub as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime, - templateRegistryStub as unknown as import('../src/graph-core/templateRegistry').TemplateRegistry, - ); - expect(slackTrigger).toBeInstanceOf(SlackTrigger); - + it('constructs RemindMeNode with stubs', () => { const remindMeNode = new RemindMeNode( eventsBusStub as unknown as EventsBusService, prismaStub as unknown as PrismaService, diff --git a/packages/platform-server/__tests__/nodes/slack-trigger.node.spec.ts b/packages/platform-server/__tests__/nodes/slack-trigger.node.spec.ts deleted file mode 100644 index 55915efb4..000000000 --- a/packages/platform-server/__tests__/nodes/slack-trigger.node.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import 'reflect-metadata'; -import { describe, expect, it } from 'vitest'; - -import { createNodeTestingModule } from './node-di.helper'; -import { SlackTrigger } from '../../src/nodes/slackTrigger/slackTrigger.node'; - -describe('SlackTrigger DI', () => { - it('compiles via Nest testing module', async () => { - const moduleRef = await createNodeTestingModule(SlackTrigger); - try { - expect(moduleRef).toBeTruthy(); - } finally { - await moduleRef.close(); - } - }); -}); diff --git a/packages/platform-server/__tests__/routes.variables.test.ts b/packages/platform-server/__tests__/routes.variables.test.ts index b4da6a32c..a940442cc 100644 --- a/packages/platform-server/__tests__/routes.variables.test.ts +++ b/packages/platform-server/__tests__/routes.variables.test.ts @@ -1,14 +1,34 @@ import { describe, it, expect, beforeEach } from 'vitest'; import Fastify from 'fastify'; import { GraphVariablesController } from '../src/graph/controllers/graphVariables.controller'; -import type { GraphRepository } from '../src/graph/graph.repository'; import { GraphVariablesService } from '../src/graph/services/graphVariables.service'; -import type { PersistedGraph } from '../src/shared/types/graph.types'; class InMemoryPrismaClient { + graphVariable = { + data: new Map(), + async findMany() { return Array.from(this.data.values()); }, + async findUnique(args: { where: { key: string } }) { return this.data.get(args.where.key) ?? null; }, + async create(args: { data: { key: string; value: string } }) { + const { key, value } = args.data; + this.data.set(key, { key, value }); + return { key, value }; + }, + async update(args: { where: { key: string }; data: { value: string } }) { + const key = args.where.key; + if (!this.data.has(key)) throw new Error('not_found'); + const value = args.data.value; + this.data.set(key, { key, value }); + return { key, value }; + }, + async deleteMany(args: { where: { key: string } }) { + const existed = this.data.delete(args.where.key); + return { count: existed ? 1 : 0 }; + }, + }; variableLocal = { data: new Map(), async findMany() { return Array.from(this.data.values()); }, + async findUnique(args: { where: { key: string } }) { return this.data.get(args.where.key) ?? null; }, async upsert(args: { where: { key: string }; update: { value: string }; create: { key: string; value: string } }) { const key = args.where.key; const existing = this.data.get(key); @@ -23,30 +43,14 @@ class InMemoryPrismaClient { class PrismaStub { client = new InMemoryPrismaClient(); getClient() { return this.client as unknown as any; } } -class GraphRepoStub implements GraphRepository { - private snapshot: PersistedGraph = { name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [], variables: [] }; - private conflictNextUpsert = false; - async initIfNeeded(): Promise {} - async get(name: string): Promise { return name === 'main' ? this.snapshot : null; } - async upsert(req: { name: string; version?: number; nodes: any[]; edges: any[]; variables?: Array<{ key: string; value: string }> }): Promise { - if (this.conflictNextUpsert || (req.version ?? 0) !== this.snapshot.version) { - this.conflictNextUpsert = false; - const err: any = new Error('Version conflict'); err.code = 'VERSION_CONFLICT'; err.current = this.snapshot; throw err; - } - this.snapshot = { name: 'main', version: this.snapshot.version + 1, updatedAt: new Date().toISOString(), nodes: req.nodes, edges: req.edges, variables: req.variables }; - return this.snapshot; - } - async upsertNodeState(): Promise {} - triggerConflictOnce() { this.conflictNextUpsert = true; } -} - describe('GraphVariablesController routes', () => { - let fastify: any; let prismaSvc: PrismaStub; let repo: GraphRepoStub; let controller: GraphVariablesController; + let fastify: any; let prismaSvc: PrismaStub; let controller: GraphVariablesController; beforeEach(async () => { - fastify = Fastify({ logger: false }); prismaSvc = new PrismaStub(); repo = new GraphRepoStub(); - (repo as any).snapshot.variables = [ { key: 'A', value: 'GA' }, { key: 'B', value: 'GB' } ]; + fastify = Fastify({ logger: false }); prismaSvc = new PrismaStub(); + prismaSvc.client.graphVariable.data.set('A', { key: 'A', value: 'GA' }); + prismaSvc.client.graphVariable.data.set('B', { key: 'B', value: 'GB' }); prismaSvc.client.variableLocal.data.set('B', { key: 'B', value: 'LB' }); prismaSvc.client.variableLocal.data.set('C', { key: 'C', value: 'LC' }); - const service = new GraphVariablesService(repo as unknown as GraphRepository, prismaSvc as any); + const service = new GraphVariablesService(prismaSvc as any); controller = new GraphVariablesController(service); fastify.get('/api/graph/variables', async (_req, res) => res.send(await controller.list())); // POST should return 201 like Nest's @HttpCode(201) @@ -91,19 +95,6 @@ describe('GraphVariablesController routes', () => { expect(res.json().error).toBe('BAD_VALUE'); }); - it('returns 409 on optimistic version conflict', async () => { - // force conflict on next upsert - (repo as any).triggerConflictOnce(); - const res = await fastify.inject({ method: 'POST', url: '/api/graph/variables', payload: { key: 'D', graph: 'GD' } }); - expect(res.statusCode).toBe(409); - expect(res.json().error).toBe('VERSION_CONFLICT'); - // also for PUT - (repo as any).triggerConflictOnce(); - const res2 = await fastify.inject({ method: 'PUT', url: '/api/graph/variables/A', payload: { graph: 'GA2' } }); - expect(res2.statusCode).toBe(409); - expect(res2.json().error).toBe('VERSION_CONFLICT'); - }); - it('deletes variable from graph and local override', async () => { await fastify.inject({ method: 'PUT', url: '/api/graph/variables/C', payload: { local: 'LC2' } }); const resDel = await fastify.inject({ method: 'DELETE', url: '/api/graph/variables/C' }); expect(resDel.statusCode).toBe(204); diff --git a/packages/platform-server/__tests__/slack.config.schemas.test.ts b/packages/platform-server/__tests__/slack.config.schemas.test.ts deleted file mode 100644 index 3eb9177f4..000000000 --- a/packages/platform-server/__tests__/slack.config.schemas.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -// Use dynamic imports with module mocks to avoid Prisma dependency during unit tests - -// Mock persistence service to avoid prisma module load -vi.mock('../src/agents/agents.persistence.service', () => ({ AgentsPersistenceService: class {} })); - -describe('Slack static config schemas', () => { - it('SendSlackMessageToolStaticConfigSchema: accepts xoxb- tokens or reference field', async () => { - const { SendSlackMessageToolStaticConfigSchema } = await import('../src/nodes/tools/send_slack_message/send_slack_message.tool'); - expect(() => SendSlackMessageToolStaticConfigSchema.parse({ bot_token: 'xoxb-123' })).not.toThrow(); - expect(() => - SendSlackMessageToolStaticConfigSchema.parse({ - bot_token: { kind: 'vault', path: 'secret/path', key: 'BOT' }, - }), - ).not.toThrow(); - expect(() => SendSlackMessageToolStaticConfigSchema.parse({ bot_token: { kind: 'var', name: 'SLACK_BOT_TOKEN' } })).not.toThrow(); - }, 15000); - - it('SlackTriggerStaticConfigSchema: requires app_token and bot_token reference fields', async () => { - const { SlackTriggerStaticConfigSchema } = await import('../src/nodes/slackTrigger/slackTrigger.node'); - expect(() => SlackTriggerStaticConfigSchema.parse({ app_token: 'xapp-abc', bot_token: 'xoxb-abc' })).not.toThrow(); - expect(() => - SlackTriggerStaticConfigSchema.parse({ - app_token: { kind: 'vault', path: 'secret/path', key: 'APP' }, - bot_token: { kind: 'vault', path: 'secret/path', key: 'BOT' }, - }), - ).not.toThrow(); - expect(() => - SlackTriggerStaticConfigSchema.parse({ - app_token: { kind: 'var', name: 'SLACK_APP' }, - bot_token: { kind: 'var', name: 'SLACK_BOT' }, - }), - ).not.toThrow(); - }, 15000); -}); diff --git a/packages/platform-server/__tests__/slack.pr.trigger.lifecycle.test.ts b/packages/platform-server/__tests__/slack.pr.trigger.lifecycle.test.ts deleted file mode 100644 index 423fab9a6..000000000 --- a/packages/platform-server/__tests__/slack.pr.trigger.lifecycle.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SlackTrigger } from '../src/nodes/slackTrigger/slackTrigger.node'; -import type { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; -import type { PrismaService } from '../src/core/services/prisma.service'; -import type { SlackAdapter } from '../src/messaging/slack/slack.adapter'; -import { createReferenceResolverStub } from './helpers/reference-resolver.stub'; - -// Mock @slack/socket-mode to avoid network/real client -const socketClients: Array<{ start: ReturnType; disconnect: ReturnType }> = []; - -vi.mock('@slack/socket-mode', () => { - class MockClient { - public start = vi.fn(async () => {}); - public disconnect = vi.fn(async () => {}); - handlers: Record unknown>> = {}; - on(ev: string, fn: (...args: unknown[]) => unknown) { - this.handlers[ev] = this.handlers[ev] || []; - this.handlers[ev].push(fn); - } - constructor() { - socketClients.push({ start: this.start, disconnect: this.disconnect }); - } - } - return { SocketModeClient: MockClient }; -}); -// PRTrigger path pending refactor; mark lifecycle test skipped until clarified. - -describe('SlackTrigger and PRTrigger lifecycle', () => { - beforeEach(() => { vi.useRealTimers(); }); - - it('SlackTrigger start/stop manages socket-mode lifecycle', async () => { - const persistence = { - getOrCreateThreadByAlias: async (_src: string, _alias: string, _summary: string) => 't-slack', - updateThreadChannelDescriptor: async () => undefined, - ensureAssignedAgent: async () => undefined, - } as unknown as AgentsPersistenceService; - const prisma = { - getClient: () => ({ - thread: { findUnique: vi.fn(async () => ({ channel: null })) }, - }), - } as any; - const slackAdapter = { - sendText: vi.fn(async () => ({ ok: true, channelMessageId: '1', threadId: '1' })), - } as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trigger = new SlackTrigger(referenceResolver, persistence, prisma as PrismaService, slackAdapter, runtimeStub, templateRegistryStub); - await trigger.setConfig({ app_token: 'xapp-test', bot_token: 'xoxb-test' }); - await trigger.provision(); - await trigger.deprovision(); - expect(socketClients).toHaveLength(1); - expect(socketClients[0].start).toHaveBeenCalledTimes(1); - expect(socketClients[0].disconnect).toHaveBeenCalledTimes(1); - }); - - // Removed obsolete PRTrigger skipped case per Issue #572. -}); diff --git a/packages/platform-server/__tests__/slack.threading.integration.test.ts b/packages/platform-server/__tests__/slack.threading.integration.test.ts deleted file mode 100644 index 6a288a487..000000000 --- a/packages/platform-server/__tests__/slack.threading.integration.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SlackTrigger } from '../src/nodes/slackTrigger/slackTrigger.node'; -import type { SlackAdapter } from '../src/messaging/slack/slack.adapter'; -import { createReferenceResolverStub } from './helpers/reference-resolver.stub'; - -type ChannelDescriptor = import('../src/messaging/types').ChannelDescriptor; - -type SlackMessageEvent = { - type: 'message'; - user: string; - channel: string; - text: string; - ts: string; - thread_ts?: string; - channel_type?: string; -}; - -type SlackEnvelope = { - envelope_id: string; - ack: () => Promise; - body: { type: 'event_callback'; event: SlackMessageEvent }; -}; - -class DescriptorStore { - private descriptors = new Map(); - private aliases = new Map(); - - getOrCreateThread(alias: string): string { - const existing = this.aliases.get(alias); - if (existing) return existing; - const id = `thread-${alias}`; - this.aliases.set(alias, id); - return id; - } - - getDescriptor(threadId: string): ChannelDescriptor | null { - return this.descriptors.get(threadId) ?? null; - } - - setDescriptor(threadId: string, descriptor: ChannelDescriptor): void { - if (!this.descriptors.has(threadId)) { - this.descriptors.set(threadId, descriptor); - } - } -} - -vi.mock('@slack/socket-mode', () => { - let last: MockClient | null = null; - class MockClient { - handlers: { message?: Array<(env: SlackEnvelope) => Promise | void> } = {}; - constructor() { - last = this; - } - on(ev: string, fn: (env: SlackEnvelope) => Promise | void) { - if (ev !== 'message') return; - this.handlers.message = this.handlers.message || []; - this.handlers.message.push(fn); - } - async start() {} - async disconnect() {} - } - const __getLastSocketClient = () => last; - return { SocketModeClient: MockClient, __getLastSocketClient }; -}); - -declare module '@slack/socket-mode' { - export function __getLastSocketClient(): { handlers: { message?: Array<(env: SlackEnvelope) => Promise | void> } } | null; -} -import { __getLastSocketClient } from '@slack/socket-mode'; - -describe('SlackTrigger threading integration', () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - }); - - const setup = async (store: DescriptorStore) => { - const getOrCreateThreadByAlias = vi.fn(async (_src: string, alias: string) => store.getOrCreateThread(alias)); - const updateThreadChannelDescriptor = vi.fn(async (threadId: string, descriptor: ChannelDescriptor) => { - store.setDescriptor(threadId, descriptor); - }); - const ensureAssignedAgent = vi.fn(async () => {}); - const persistence = ({ - getOrCreateThreadByAlias, - updateThreadChannelDescriptor, - ensureAssignedAgent, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const prismaStub = ({ - getClient: () => ({ - thread: { - findUnique: async ({ where: { id } }: { where: { id: string } }) => ({ channel: store.getDescriptor(id) }), - }, - }), - } satisfies Pick) as import('../src/core/services/prisma.service').PrismaService; - const slackSend = vi.fn(async (opts: { token: string; channel: string; text: string; thread_ts?: string }) => ({ ok: true, channelMessageId: '200', threadId: opts.thread_ts ?? 'generated-thread' })); - const slackAdapter = ({ sendText: slackSend } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => ['agent-slack'], - getNodes: () => [{ id: 'agent-slack', template: 'agent' }], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: (template: string) => (template === 'agent' ? { kind: 'agent', title: 'Agent' } : undefined) } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trigger = new SlackTrigger(referenceResolver, persistence, prismaStub, slackAdapter, runtimeStub, templateRegistryStub); - trigger.init({ nodeId: 'slack-node' }); - await trigger.setConfig({ app_token: 'xapp-abc', bot_token: 'xoxb-bot' }); - await trigger.provision(); - const client = __getLastSocketClient(); - if (!client || !(client.handlers.message || []).length) throw new Error('socket not initialized'); - const handler = (client.handlers.message || [])[0]!; - return { - trigger, - handler, - slackSend, - getOrCreateThreadByAlias, - updateThreadChannelDescriptor, - ensureAssignedAgent, - }; - }; - - it('replies to top-level channel messages within the same thread', async () => { - const store = new DescriptorStore(); - const { handler, trigger, slackSend, updateThreadChannelDescriptor, ensureAssignedAgent } = await setup(store); - const ack = vi.fn(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'env1', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'U1', - channel: 'C1', - text: 'hello world', - ts: '1000.1', - channel_type: 'channel', - }, - }, - }; - await handler(env); - const threadId = 'thread-U1_1000.1'; - await trigger.sendToChannel(threadId, 'ack'); - expect(slackSend).toHaveBeenCalledWith({ token: 'xoxb-bot', channel: 'C1', text: 'ack', thread_ts: '1000.1' }); - const descriptor = store.getDescriptor(threadId); - expect(descriptor?.identifiers.thread_ts).toBe('1000.1'); - expect(updateThreadChannelDescriptor).toHaveBeenCalledTimes(1); - expect(ensureAssignedAgent).toHaveBeenCalledWith(threadId, 'agent-slack'); - }); - - it('keeps reply events in their originating Slack thread', async () => { - const store = new DescriptorStore(); - const { handler, trigger, slackSend, updateThreadChannelDescriptor } = await setup(store); - const topLevelAck = vi.fn(async () => {}); - await handler({ - envelope_id: 'env-top', - ack: topLevelAck, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'U2', - channel: 'C2', - text: 'root text', - ts: '2000.9', - channel_type: 'im', - }, - }, - }); - expect(updateThreadChannelDescriptor).toHaveBeenCalledTimes(1); - const ack = vi.fn(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'env2', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'U2', - channel: 'C2', - text: 'reply text', - ts: '1001.5', - thread_ts: '2000.9', - channel_type: 'im', - }, - }, - }; - await handler(env); - const threadId = 'thread-U2_2000.9'; - await trigger.sendToChannel(threadId, 'follow-up'); - expect(slackSend).toHaveBeenCalledWith({ token: 'xoxb-bot', channel: 'C2', text: 'follow-up', thread_ts: '2000.9' }); - const descriptor = store.getDescriptor(threadId); - expect(descriptor?.identifiers.thread_ts).toBe('2000.9'); - expect(updateThreadChannelDescriptor).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/platform-server/__tests__/slack.trigger.events.test.ts b/packages/platform-server/__tests__/slack.trigger.events.test.ts deleted file mode 100644 index 21fa219ec..000000000 --- a/packages/platform-server/__tests__/slack.trigger.events.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { HumanMessage } from '@agyn/llm'; -// BaseTrigger legacy removed in Issue #451; use SlackTrigger semantics only -// Typed helper for Slack socket-mode envelope used by our handler -type SlackMessageEvent = { - type: 'message'; - user: string; - channel: string; - text: string; - ts: string; - thread_ts?: string; - channel_type?: string; - client_msg_id?: string; - event_ts?: string; - subtype?: string; -}; -type SlackEnvelope = - | { - envelope_id: string; - ack: () => Promise; - body: { type: 'event_callback'; event: SlackMessageEvent }; - } - | { - envelope_id: string; - ack: () => Promise; - body: { type: 'events_api'; payload: { event: SlackMessageEvent } }; - } - | { - envelope_id: string; - ack: () => Promise; - event: SlackMessageEvent; - body?: undefined; - }; -// Mock socket-mode client; SlackTrigger registers a 'message' handler -vi.mock('@slack/socket-mode', () => { - let last: MockClient | null = null; - class MockClient { - // Expose a typed 'message' handlers collection to avoid broad casts - handlers: { message?: Array<(env: SlackEnvelope) => Promise | void> } = {}; - constructor() { last = this; } - on(ev: string, fn: (env: SlackEnvelope) => Promise | void) { - if (ev !== 'message') return; // only route message events in tests - this.handlers.message = this.handlers.message || []; - this.handlers.message.push(fn); - } - async start() {} - async disconnect() {} - } - const __getLastSocketClient = () => last; - return { SocketModeClient: MockClient, __getLastSocketClient }; -}); -vi.mock('@prisma/client', () => { - class PrismaClient {} - const AnyNull = Symbol('AnyNull'); - const DbNull = Symbol('DbNull'); - return { - PrismaClient, - RunEventType: { - invocation_message: 'invocation_message', - injection: 'injection', - llm_call: 'llm_call', - tool_execution: 'tool_execution', - summarization: 'summarization', - }, - RunEventStatus: { - pending: 'pending', - running: 'running', - success: 'success', - error: 'error', - cancelled: 'cancelled', - }, - ToolExecStatus: { - pending: 'pending', - running: 'running', - success: 'success', - error: 'error', - }, - EventSourceKind: { - agent: 'agent', - system: 'system', - tool: 'tool', - reminder: 'reminder', - summarizer: 'summarizer', - user: 'user', - }, - AttachmentKind: { - input_text: 'input_text', - llm_prompt: 'llm_prompt', - llm_response: 'llm_response', - tool_input: 'tool_input', - tool_output: 'tool_output', - metadata: 'metadata', - }, - ContextItemRole: { - system: 'system', - user: 'user', - assistant: 'assistant', - tool: 'tool', - memory: 'memory', - summary: 'summary', - other: 'other', - }, - Prisma: { JsonNull: null, AnyNull, DbNull }, - }; -}); -// Mock PrismaService to avoid loading @prisma/client in unit tests -vi.mock('../src/core/services/prisma.service', () => { - class PrismaServiceMock { - getClient() { - return { thread: { findUnique: async () => ({ channel: null }) } }; - } - } - return { PrismaService: PrismaServiceMock }; -}); -// Type augmentation for mocked helper -declare module '@slack/socket-mode' { - export function __getLastSocketClient(): { handlers: { message?: Array<(env: SlackEnvelope) => Promise | void> } } | null; -} -import { SlackTrigger } from '../src/nodes/slackTrigger/slackTrigger.node'; -import { __getLastSocketClient } from '@slack/socket-mode'; -import type { SlackAdapter } from '../src/messaging/slack/slack.adapter'; -import { ResolveError } from '../src/utils/references'; -import { createReferenceResolverStub } from './helpers/reference-resolver.stub'; -// Avoid importing AgentsPersistenceService to prevent @prisma/client load in unit tests -// We pass a stub object where needed. - -import type { BufferMessage } from '../src/nodes/agent/messagesBuffer'; - -describe('SlackTrigger events', () => { - const setupTrigger = async (options: { nodeTemplate?: string; templateMeta?: { kind: 'agent' | 'tool'; title: string } } = {}) => { - const getOrCreateThreadByAlias = vi.fn(async () => 't-slack'); - const updateThreadChannelDescriptor = vi.fn(async () => undefined); - const ensureAssignedAgent = vi.fn(async () => {}); - const persistence = ({ - getOrCreateThreadByAlias, - updateThreadChannelDescriptor, - ensureAssignedAgent, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const prismaStub = ({ getClient: () => ({ thread: { findUnique: async () => ({ channel: null }) } }) } satisfies Pick) as import('../src/core/services/prisma.service').PrismaService; - const slackAdapterStub = ({ sendText: vi.fn() } satisfies Pick) as SlackAdapter; - const nodeTemplate = options.nodeTemplate ?? 'agent'; - const runtimeStub = ({ - getOutboundNodeIds: () => ['agent-rt-1'], - getNodes: () => [{ id: 'agent-rt-1', template: nodeTemplate }], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ - getMeta: (template: string) => { - if (template === nodeTemplate) return options.templateMeta ?? { kind: 'agent', title: 'Agent' }; - return undefined; - }, - } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trig = new SlackTrigger(referenceResolver, persistence, prismaStub, slackAdapterStub, runtimeStub, templateRegistryStub); - const nodeId = 'slack-node'; - trig.init({ nodeId }); - await trig.setConfig({ app_token: 'xapp-abc', bot_token: 'xoxb-bot' }); - const received: BufferMessage[] = []; - const listener = { invoke: vi.fn(async (_t: string, msgs: BufferMessage[]) => { received.push(...msgs); }) }; - await trig.subscribe(listener); - await trig.provision(); - const client = __getLastSocketClient(); - if (!client || !(client.handlers.message || []).length) throw new Error('Mock SocketMode client not initialized'); - const handler = (client.handlers.message || [])[0]!; - return { - handler, - received, - listenerInvoke: listener.invoke, - getOrCreateThreadByAlias, - updateThreadChannelDescriptor, - ensureAssignedAgent, - trig, - nodeId, - }; - }; - - it('persists descriptor for top-level events with root thread ts set to event ts', async () => { - const { handler, received, getOrCreateThreadByAlias, updateThreadChannelDescriptor, ensureAssignedAgent, nodeId } = await setupTrigger(); - const ack = vi.fn<[], Promise>(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'e1', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'U', - channel: 'C', - text: 'hello', - ts: '1.0', - channel_type: 'channel', - client_msg_id: 'client-1', - event_ts: 'evt-1', - }, - }, - }; - await handler(env); - expect(received.length).toBe(1); - expect(received[0]).toBeInstanceOf(HumanMessage); - expect((received[0] as HumanMessage).text).toBe('From User:\nhello'); - expect(ack).toHaveBeenCalledTimes(1); - expect(getOrCreateThreadByAlias).toHaveBeenCalledWith('slack', 'U_1.0', 'hello', { - channelNodeId: nodeId, - }); - expect(ensureAssignedAgent).toHaveBeenCalledWith('t-slack', 'agent-rt-1'); - expect(updateThreadChannelDescriptor).toHaveBeenCalledWith( - 't-slack', - expect.objectContaining({ - identifiers: { channel: 'C', thread_ts: '1.0' }, - meta: expect.objectContaining({ channel_type: 'channel', client_msg_id: 'client-1', event_ts: 'evt-1' }), - }), - ); - }); - - it('does not overwrite descriptor for reply events', async () => { - const { handler, received, getOrCreateThreadByAlias, updateThreadChannelDescriptor, ensureAssignedAgent, nodeId } = await setupTrigger(); - const ack = vi.fn<[], Promise>(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'reply', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'UR', - channel: 'CR', - text: 'reply content', - ts: '5.0', - thread_ts: 'root-5', - }, - }, - }; - await handler(env); - expect(received.length).toBe(1); - expect(ack).toHaveBeenCalledTimes(1); - expect(getOrCreateThreadByAlias).toHaveBeenCalledWith('slack', 'UR_root-5', 'reply content', { - channelNodeId: nodeId, - }); - expect(updateThreadChannelDescriptor).not.toHaveBeenCalled(); - expect(ensureAssignedAgent).toHaveBeenCalledWith('t-slack', 'agent-rt-1'); - }); - - it('assigns agent using template metadata when template name differs', async () => { - const { handler, ensureAssignedAgent } = await setupTrigger({ nodeTemplate: 'custom.agent', templateMeta: { kind: 'agent', title: 'Custom Agent' } }); - const ack = vi.fn<[], Promise>(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'meta', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'U', - channel: 'C', - text: 'hello', - ts: 'meta-1', - }, - }, - }; - await handler(env); - expect(ensureAssignedAgent).toHaveBeenCalledWith('t-slack', 'agent-rt-1'); - }); - - it('relays message events from socket-mode events_api payload', async () => { - const { handler, received, getOrCreateThreadByAlias, updateThreadChannelDescriptor, nodeId } = await setupTrigger(); - const ack = vi.fn<[], Promise>(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'e2', - ack, - body: { - type: 'events_api', - payload: { - event: { - type: 'message', - user: 'U2', - channel: 'C2', - text: 'hello socket', - ts: '2.0', - }, - }, - }, - }; - await handler(env); - expect(received.length).toBe(1); - expect(received[0]).toBeInstanceOf(HumanMessage); - expect((received[0] as HumanMessage).text).toBe('From User:\nhello socket'); - expect(ack).toHaveBeenCalledTimes(1); - expect(getOrCreateThreadByAlias).toHaveBeenCalledWith('slack', 'U2_2.0', 'hello socket', { - channelNodeId: nodeId, - }); - expect(updateThreadChannelDescriptor).toHaveBeenCalledWith( - 't-slack', - expect.objectContaining({ identifiers: { channel: 'C2', thread_ts: '2.0' } }), - ); - }); - - it('falls back to envelope.event when body payload missing', async () => { - const { handler, received, getOrCreateThreadByAlias, updateThreadChannelDescriptor, nodeId } = await setupTrigger(); - const ack = vi.fn<[], Promise>(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'e3', - ack, - event: { - type: 'message', - user: 'UF', - channel: 'CF', - text: 'fallback', - ts: '3.0', - }, - }; - await handler(env); - expect(received.length).toBe(1); - expect(received[0]).toBeInstanceOf(HumanMessage); - expect((received[0] as HumanMessage).text).toBe('From User:\nfallback'); - expect(ack).toHaveBeenCalledTimes(1); - expect(getOrCreateThreadByAlias).toHaveBeenCalledWith('slack', 'UF_3.0', 'fallback', { - channelNodeId: nodeId, - }); - expect(updateThreadChannelDescriptor).toHaveBeenCalledWith( - 't-slack', - expect.objectContaining({ identifiers: { channel: 'CF', thread_ts: '3.0' } }), - ); - }); - - it('preserves multiline slack content in human message text', async () => { - const { handler, received } = await setupTrigger(); - const ack = vi.fn<[], Promise>(async () => {}); - const text = 'first line\nsecond line'; - const env: SlackEnvelope = { - envelope_id: 'multiline', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'UM', - channel: 'CM', - text, - ts: '9.0', - }, - }, - }; - await handler(env); - expect(received.length).toBe(1); - expect((received[0] as HumanMessage).text).toBe(`From User:\n${text}`); - }); - - it('acks and filters out non-message or subtype events without notifying listeners', async () => { - const { handler, received, listenerInvoke, updateThreadChannelDescriptor, getOrCreateThreadByAlias } = await setupTrigger(); - const ack = vi.fn<[], Promise>(async () => {}); - const env: SlackEnvelope = { - envelope_id: 'e4', - ack, - body: { - type: 'event_callback', - event: { - type: 'message', - user: 'UX', - channel: 'CX', - text: 'should not dispatch', - ts: '4.0', - subtype: 'bot_message', - }, - }, - }; - await handler(env); - expect(ack).toHaveBeenCalledTimes(1); - expect(received.length).toBe(0); - expect(listenerInvoke).not.toHaveBeenCalled(); - expect(getOrCreateThreadByAlias).not.toHaveBeenCalled(); - expect(updateThreadChannelDescriptor).not.toHaveBeenCalled(); - }); - - it('setConfig rejects tokens the resolver leaves unresolved', async () => { - const persistence = ({ - getOrCreateThreadByAlias: async () => 't-slack', - ensureAssignedAgent: async () => undefined, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const prismaStub = ({ getClient: () => ({ thread: { findUnique: async () => ({ channel: null }) } }) } satisfies Pick) as import('../src/core/services/prisma.service').PrismaService; - const slackAdapterStub = ({ sendText: vi.fn() } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trig = new SlackTrigger(referenceResolver, persistence, prismaStub, slackAdapterStub, runtimeStub, templateRegistryStub); - const badConfig = { - app_token: { kind: 'vault', path: 'secret/slack', key: 'APP' }, - bot_token: 'xoxb-good', - } as any; - await expect(trig.setConfig(badConfig)).rejects.toThrow(/Slack app_token is required/); - }); - - it('fails provisioning when bot token prefix invalid', async () => { - const persistence = ({ - getOrCreateThreadByAlias: async () => 't-slack', - ensureAssignedAgent: async () => undefined, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const prismaStub = ({ getClient: () => ({ thread: { findUnique: async () => ({ channel: null }) } }) } satisfies Pick) as import('../src/core/services/prisma.service').PrismaService; - const slackAdapterStub = ({ sendText: vi.fn() } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trig = new SlackTrigger(referenceResolver, persistence, prismaStub, slackAdapterStub, runtimeStub, templateRegistryStub); - await expect(trig.setConfig({ app_token: 'xapp-valid', bot_token: 'bot-invalid' })).rejects.toThrow( - /Slack bot token must start with xoxb-/, - ); - }); - - it('resolves tokens via reference resolver', async () => { - const resolver = { - resolve: vi.fn(async () => ({ - output: { app_token: 'xapp-from-resolver', bot_token: 'xoxb-from-resolver' }, - report: { events: [], counts: { total: 0, resolved: 0, unresolved: 0, cacheHits: 0, errors: 0 } }, - })), - } as any; - const persistence = ({ - getOrCreateThreadByAlias: async () => 't-slack', - ensureAssignedAgent: async () => undefined, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const prismaStub = ({ getClient: () => ({ thread: { findUnique: async () => ({ channel: null }) } }) } satisfies Pick) as import('../src/core/services/prisma.service').PrismaService; - const slackAdapterStub = ({ sendText: vi.fn() } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const trig = new SlackTrigger(resolver, persistence, prismaStub, slackAdapterStub, runtimeStub, templateRegistryStub); - await trig.setConfig({ - app_token: { kind: 'vault', path: 'secret/slack', key: 'APP' } as any, - bot_token: { kind: 'var', name: 'SLACK_BOT', default: 'xoxb-from-resolver' } as any, - }); - await trig.provision(); - expect(resolver.resolve).toHaveBeenCalled(); - expect(trig.status).toBe('ready'); - }); - - it('surface resolver errors during setConfig', async () => { - const resolver = { - resolve: vi.fn(async () => { - throw new ResolveError('provider_missing', 'vault unavailable', { - path: '/slack/app_token', - source: 'secret', - }); - }), - } as any; - const persistence = ({ - getOrCreateThreadByAlias: async () => 't-slack', - ensureAssignedAgent: async () => undefined, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const prismaStub = ({ getClient: () => ({ thread: { findUnique: async () => ({ channel: null }) } }) } satisfies Pick) as import('../src/core/services/prisma.service').PrismaService; - const slackAdapterStub = ({ sendText: vi.fn() } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const trig = new SlackTrigger(resolver, persistence, prismaStub, slackAdapterStub, runtimeStub, templateRegistryStub); - await expect( - trig.setConfig({ - app_token: { kind: 'vault', path: 'secret/slack', key: 'APP' } as any, - bot_token: 'xoxb-good', - }), - ).rejects.toThrow(/Slack token resolution failed/); - }); -}); diff --git a/packages/platform-server/__tests__/tools.send_message.integration.test.ts b/packages/platform-server/__tests__/tools.send_message.integration.test.ts index 4102cbdff..39f3ec75b 100644 --- a/packages/platform-server/__tests__/tools.send_message.integration.test.ts +++ b/packages/platform-server/__tests__/tools.send_message.integration.test.ts @@ -1,38 +1,11 @@ import { describe, it, expect } from 'vitest'; import { SendMessageFunctionTool } from '../src/nodes/tools/send_message/send_message.tool'; -// Avoid importing PrismaService to prevent prisma client load -import { SlackTrigger } from '../src/nodes/slackTrigger/slackTrigger.node'; -import type { SlackAdapter } from '../src/messaging/slack/slack.adapter'; import type { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; -import { ThreadTransportService } from '../src/messaging/threadTransport.service'; -import { createReferenceResolverStub } from './helpers/reference-resolver.stub'; +import { ThreadTransportService, type ThreadChannelNode } from '../src/messaging/threadTransport.service'; import type { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; +import type { SendResult } from '../src/messaging/types'; -// Mock slack web api import { vi } from 'vitest'; -vi.mock('@slack/socket-mode', () => { - class MockSocket { - on() {} - async start() {} - async disconnect() {} - } - return { SocketModeClient: MockSocket }; -}); -vi.mock('@slack/web-api', () => { - type ChatPostMessageArguments = { channel: string; text: string; thread_ts?: string }; - type ChatPostMessageResponse = { ok: boolean; channel?: string; ts?: string; message?: { thread_ts?: string } }; - class WebClient { - chat = { - postMessage: vi.fn(async (opts: ChatPostMessageArguments): Promise => ({ - ok: true, - channel: opts.channel, - ts: '2001', - message: { thread_ts: opts.thread_ts || '2001' }, - })), - }; - } - return { WebClient }; -}); describe('send_message tool', () => { const makePrismaStub = (options: { channelNodeId?: string | null; channel?: unknown | null; exists?: boolean }) => { @@ -61,46 +34,8 @@ describe('send_message tool', () => { getNodeInstance: vi.fn((nodeId: string) => instances?.[nodeId]), } satisfies Partial) as LiveGraphRuntime; - const makeTrigger = async ( - prismaService: import('../src/core/services/prisma.service').PrismaService, - options: { descriptor?: unknown; sendResult?: import('../src/messaging/types').SendResult }, - ) => { - const descriptor = options.descriptor ?? { - type: 'slack', - identifiers: { channel: 'C1', thread_ts: '123' }, - meta: {}, - version: 1, - }; - const sendResult = options.sendResult ?? { ok: true, channelMessageId: '2001', threadId: '2001' }; - - const persistence = ({ - getOrCreateThreadByAlias: async () => 't1', - updateThreadChannelDescriptor: async () => undefined, - ensureAssignedAgent: async () => undefined, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const slackSend = vi.fn(async () => sendResult); - const slackAdapter = ({ sendText: slackSend } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trigger = new SlackTrigger(referenceResolver, persistence, prismaService, slackAdapter, runtimeStub, templateRegistryStub); - trigger.init({ nodeId: 'channel-node' }); - - // Override prisma behavior for descriptor lookup inside sendToChannel - const client = prismaService.getClient(); - const originalFindUnique = client.thread.findUnique; - client.thread.findUnique = vi.fn(async (args: { select: Record }) => { - if (args.select?.channel) return { channel: descriptor }; - return originalFindUnique(args); - }); - - await trigger.setConfig({ app_token: 'xapp-abc', bot_token: 'xoxb-abc' }); - await trigger.provision(); - return { trigger, slackSend }; - }; + const makeChannelNode = (result: SendResult) => + ({ sendToChannel: vi.fn(async () => result) } satisfies ThreadChannelNode); it('persists message when thread has no channel node', async () => { const { prismaService } = makePrismaStub({ channelNodeId: null }); @@ -137,7 +72,7 @@ describe('send_message tool', () => { expect(transportPersistence.recordTransportAssistantMessage).not.toHaveBeenCalled(); }); - it('returns error when runtime node is not SlackTrigger', async () => { + it('returns error when runtime node lacks channel adapter', async () => { const { prismaService } = makePrismaStub({ channelNodeId: 'node-x' }); const runtime = makeRuntimeStub({ 'node-x': {} }); const { transport, persistence: transportPersistence } = makeTransport(prismaService, runtime); @@ -146,39 +81,10 @@ describe('send_message tool', () => { expect(res).toBe('unsupported_channel_node'); expect(transportPersistence.recordTransportAssistantMessage).not.toHaveBeenCalled(); }); - - it('returns error when trigger is not ready', async () => { + it('propagates channel send errors', async () => { const { prismaService } = makePrismaStub({ channelNodeId: 'channel-node' }); - const persistence = ({ - getOrCreateThreadByAlias: async () => 't1', - updateThreadChannelDescriptor: async () => undefined, - ensureAssignedAgent: async () => undefined, - } satisfies Pick) as import('../src/agents/agents.persistence.service').AgentsPersistenceService; - const slackAdapter = ({ sendText: vi.fn() } satisfies Pick) as SlackAdapter; - const runtimeStub = ({ - getOutboundNodeIds: () => [], - getNodes: () => [], - } satisfies Pick) as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const templateRegistryStub = ({ getMeta: () => undefined } satisfies Pick) as import('../src/graph-core/templateRegistry').TemplateRegistry; - const { stub: referenceResolver } = createReferenceResolverStub(); - const trigger = new SlackTrigger(referenceResolver, persistence, prismaService, slackAdapter, runtimeStub, templateRegistryStub); - trigger.init({ nodeId: 'channel-node' }); - const runtime = makeRuntimeStub({ 'channel-node': trigger }); - const { transport, persistence: transportPersistence } = makeTransport(prismaService, runtime); - const tool = new SendMessageFunctionTool(transport); - const res = await tool.execute({ message: 'hello' }, { threadId: 't1' } as any); - expect(res).toBe('slacktrigger_unprovisioned'); - expect(transportPersistence.recordTransportAssistantMessage).not.toHaveBeenCalled(); - }); - - it('propagates SlackTrigger send errors', async () => { - const { prismaService, state } = makePrismaStub({ channelNodeId: 'channel-node' }); - state.channel = null; - const { trigger } = await makeTrigger(prismaService, { - descriptor: null, - sendResult: { ok: false, error: 'missing_channel_descriptor' }, - }); - const runtime = makeRuntimeStub({ 'channel-node': trigger }); + const channelNode = makeChannelNode({ ok: false, error: 'missing_channel_descriptor' }); + const runtime = makeRuntimeStub({ 'channel-node': channelNode }); const { transport, persistence: transportPersistence } = makeTransport(prismaService, runtime); const tool = new SendMessageFunctionTool(transport); const res = await tool.execute({ message: 'hello' }, { threadId: 't1' } as any); @@ -186,15 +92,15 @@ describe('send_message tool', () => { expect(transportPersistence.recordTransportAssistantMessage).not.toHaveBeenCalled(); }); - it('sends via SlackTrigger when ready', async () => { + it('sends via channel adapter when ready', async () => { const { prismaService } = makePrismaStub({ channelNodeId: 'channel-node' }); - const { trigger, slackSend } = await makeTrigger(prismaService, {}); - const runtime = makeRuntimeStub({ 'channel-node': trigger }); + const channelNode = makeChannelNode({ ok: true, channelMessageId: '2001', threadId: '2001' }); + const runtime = makeRuntimeStub({ 'channel-node': channelNode }); const { transport, persistence: transportPersistence } = makeTransport(prismaService, runtime); const tool = new SendMessageFunctionTool(transport); const res = await tool.execute({ message: 'hello' }, { threadId: 't1', runId: 'run-1' } as any); expect(res).toBe('message sent successfully'); - expect(slackSend).toHaveBeenCalledWith({ token: 'xoxb-abc', channel: 'C1', text: 'hello', thread_ts: '123' }); + expect(channelNode.sendToChannel).toHaveBeenCalledWith('t1', 'hello'); expect(transportPersistence.recordTransportAssistantMessage).toHaveBeenCalledWith({ threadId: 't1', text: 'hello', diff --git a/packages/platform-server/prisma/migrations/20251222120000_graph_state_tables/migration.sql b/packages/platform-server/prisma/migrations/20251222120000_graph_state_tables/migration.sql new file mode 100644 index 000000000..3ab63ad41 --- /dev/null +++ b/packages/platform-server/prisma/migrations/20251222120000_graph_state_tables/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "GraphVariable" ( + "id" SERIAL NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GraphVariable_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GraphNodeState" ( + "id" SERIAL NOT NULL, + "nodeId" TEXT NOT NULL, + "state" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GraphNodeState_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GraphVariable_key_key" ON "GraphVariable"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "GraphNodeState_nodeId_key" ON "GraphNodeState"("nodeId"); diff --git a/packages/platform-server/prisma/schema.prisma b/packages/platform-server/prisma/schema.prisma index 57c535ee4..0266babc8 100644 --- a/packages/platform-server/prisma/schema.prisma +++ b/packages/platform-server/prisma/schema.prisma @@ -30,6 +30,24 @@ model VariableLocal { updatedAt DateTime @updatedAt } +// Graph-level variables persisted outside the Teams graph source +model GraphVariable { + id Int @id @default(autoincrement()) + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Persisted runtime state for graph nodes (MCP enabled tools, etc.) +model GraphNodeState { + id Int @id @default(autoincrement()) + nodeId String @unique + state Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model LiteLLMVirtualKey { id Int @id @default(autoincrement()) alias String @unique diff --git a/packages/platform-server/src/graph-domain/graph-domain.module.ts b/packages/platform-server/src/graph-domain/graph-domain.module.ts index 04f32be7f..251b5b3a5 100644 --- a/packages/platform-server/src/graph-domain/graph-domain.module.ts +++ b/packages/platform-server/src/graph-domain/graph-domain.module.ts @@ -1,16 +1,13 @@ import { Global, Module } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; import { CoreModule } from '../core/core.module'; -import { ConfigService } from '../core/services/config.service'; import { EventsModule } from '../events/events.module'; import { InfraModule } from '../infra/infra.module'; import { EnvModule } from '../env/env.module'; import { LLMModule } from '../llm/llm.module'; import { VaultModule } from '../vault/vault.module'; import { GraphRepository } from '../graph/graph.repository'; -import { FsGraphRepository } from '../graph/fsGraph.repository'; -import { HybridGraphRepository } from '../graph/hybridGraph.repository'; import { TeamsGraphSource } from '../graph/teamsGraph.source'; +import { TeamsGraphRepository } from '../graph/teamsGraph.repository'; import { NodesModule } from '../nodes/nodes.module'; import { AgentsPersistenceService } from '../agents/agents.persistence.service'; import { ThreadsMetricsService } from '../agents/threads.metrics.service'; @@ -18,7 +15,6 @@ import { RunSignalsRegistry } from '../agents/run-signals.service'; import { CallAgentLinkingService } from '../agents/call-agent-linking.service'; import { ThreadCleanupCoordinator } from '../agents/threadCleanup.coordinator'; import { RemindersService } from '../agents/reminders.service'; -import { TemplateRegistry } from '../graph-core/templateRegistry'; import { TeamsModule } from '../teams/teams.module'; @Global() @@ -31,15 +27,10 @@ import { TeamsModule } from '../teams/teams.module'; ThreadCleanupCoordinator, RemindersService, TeamsGraphSource, + TeamsGraphRepository, { provide: GraphRepository, - useFactory: async (config: ConfigService, moduleRef: ModuleRef, teamsSource: TeamsGraphSource) => { - const templateRegistry = await moduleRef.resolve(TemplateRegistry, undefined, { strict: false }); - const fsRepo = new FsGraphRepository(config, templateRegistry); - await fsRepo.initIfNeeded(); - return new HybridGraphRepository(fsRepo, teamsSource); - }, - inject: [ConfigService, ModuleRef, TeamsGraphSource], + useExisting: TeamsGraphRepository, }, AgentsPersistenceService, ], diff --git a/packages/platform-server/src/graph/controllers/graphVariables.controller.ts b/packages/platform-server/src/graph/controllers/graphVariables.controller.ts index 168ec67b6..4a5030600 100644 --- a/packages/platform-server/src/graph/controllers/graphVariables.controller.ts +++ b/packages/platform-server/src/graph/controllers/graphVariables.controller.ts @@ -17,7 +17,6 @@ export class GraphVariablesController { try { return await this.service.create('main', parsed.key, parsed.graph); } catch (e: unknown) { if (isCodeError(e) && e.code === 'DUPLICATE_KEY') throw new HttpException({ error: 'DUPLICATE_KEY' }, HttpStatus.CONFLICT); - if (isCodeError(e) && e.code === 'VERSION_CONFLICT') throw new HttpException({ error: 'VERSION_CONFLICT', current: e.current }, HttpStatus.CONFLICT); throw e; } } @@ -27,9 +26,7 @@ export class GraphVariablesController { const parsed = parseUpdateBody(body); try { return await this.service.update('main', key, parsed); } catch (e: unknown) { - if (isCodeError(e) && e.code === 'GRAPH_NOT_FOUND') throw new HttpException({ error: 'GRAPH_NOT_FOUND' }, HttpStatus.NOT_FOUND); if (isCodeError(e) && e.code === 'KEY_NOT_FOUND') throw new HttpException({ error: 'KEY_NOT_FOUND' }, HttpStatus.NOT_FOUND); - if (isCodeError(e) && e.code === 'VERSION_CONFLICT') throw new HttpException({ error: 'VERSION_CONFLICT', current: e.current }, HttpStatus.CONFLICT); throw e; } } @@ -37,11 +34,7 @@ export class GraphVariablesController { @Delete(':key') @HttpCode(204) async remove(@Param('key') key: string): Promise { - try { await this.service.remove('main', key); } - catch (e: unknown) { - if (isCodeError(e) && e.code === 'VERSION_CONFLICT') throw new HttpException({ error: 'VERSION_CONFLICT', current: e.current }, HttpStatus.CONFLICT); - throw e; - } + await this.service.remove('main', key); } } diff --git a/packages/platform-server/src/graph/fsGraph.repository.ts b/packages/platform-server/src/graph/fsGraph.repository.ts deleted file mode 100644 index caf0fbe58..000000000 --- a/packages/platform-server/src/graph/fsGraph.repository.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { Dirent, promises as fs } from 'fs'; -import path from 'path'; -import { TemplateRegistry } from '../graph-core/templateRegistry'; -import type { - PersistedGraph, - PersistedGraphEdge, - PersistedGraphNode, - PersistedGraphUpsertRequest, - PersistedGraphUpsertResponse, -} from '../shared/types/graph.types'; -import { validatePersistedGraph } from './graphSchema.validator'; -import { GraphRepository } from './graph.repository'; -import type { GraphAuthor } from './graph.repository'; -import { ConfigService } from '../core/services/config.service'; -import { parseYaml, stringifyYaml } from './yaml.util'; - -interface GraphMeta { - name: string; - version: number; - updatedAt: string; - format: 2; -} - -const STAGING_PREFIX = '.graph-staging-'; -const BACKUP_PREFIX = '.graph-backup-'; - -type CodeError = Error & { code: string; current?: T }; -function codeError(code: string, message: string, current?: T): CodeError { - const err = new Error(message) as CodeError; - err.code = code; - if (current !== undefined) err.current = current; - return err; -} - -type LockHandle = { lockPath: string } | null; - -export class FsGraphRepository extends GraphRepository { - constructor( - private readonly config: ConfigService, - private readonly templateRegistry: TemplateRegistry, - ) { - super(); - } - - private graphRoot?: string; - - async initIfNeeded(): Promise { - this.graphRoot = this.config.graphRepoPath; - await this.cleanupSwapArtifacts(); - const root = this.ensureGraphRoot(); - await fs.mkdir(root, { recursive: true }); - await fs.mkdir(path.join(root, 'nodes'), { recursive: true }); - await fs.mkdir(path.join(root, 'edges'), { recursive: true }); - const metaPath = path.join(root, this.metaPath()); - if (!(await this.pathExists(metaPath))) { - const now = new Date().toISOString(); - const meta: GraphMeta = { name: 'main', version: 0, updatedAt: now, format: 2 }; - await this.atomicWriteFile(metaPath, stringifyYaml(meta)); - } - } - - async get(name: string): Promise { - this.assertReady(); - const working = await this.readFromWorkingTree(name); - return working ? this.cloneGraph(working) : null; - } - - async upsert(req: PersistedGraphUpsertRequest, _author?: GraphAuthor): Promise { - this.assertReady(); - validatePersistedGraph(req, await this.templateRegistry.toSchema()); - - const lock = await this.acquireLock(); - try { - const existing = await this.get(req.name); - const nowIso = new Date().toISOString(); - - if (!existing) { - if (req.version !== undefined && req.version !== 0) { - throw codeError('VERSION_CONFLICT', 'Version conflict', { - name: req.name, - version: 0, - updatedAt: nowIso, - nodes: [], - edges: [], - }); - } - } else if (req.version !== undefined && req.version !== existing.version) { - throw codeError('VERSION_CONFLICT', 'Version conflict', existing); - } - - const normalizedNodes = req.nodes.map((node) => { - const stripped = this.stripInternalNode(node); - if (stripped.state === undefined && existing) { - const prev = existing.nodes.find((n) => n.id === stripped.id); - if (prev && prev.state !== undefined) stripped.state = prev.state; - } - return stripped; - }); - const normalizedEdges = req.edges.map((edge) => { - const stripped = this.stripInternalEdge(edge); - const deterministicId = this.edgeId(stripped); - if (stripped.id && stripped.id !== deterministicId) { - throw codeError('EDGE_ID_MISMATCH', `Edge id mismatch: expected ${deterministicId} got ${stripped.id}`); - } - return { ...stripped, id: deterministicId }; - }); - - const current = - existing ?? ({ name: req.name, version: 0, updatedAt: nowIso, nodes: [], edges: [], variables: [] } as PersistedGraph); - - const target: PersistedGraph = { - name: req.name, - version: (current.version || 0) + 1, - updatedAt: nowIso, - nodes: normalizedNodes, - edges: normalizedEdges, - variables: - req.variables === undefined - ? current.variables - : req.variables.map((v) => ({ key: String(v.key), value: String(v.value) })), - }; - - try { - await this.persistGraph(current, target); - } catch (err) { - await this.restoreWorkingTree(existing ?? null); - const msg = err instanceof Error ? err.message : String(err); - throw codeError('PERSIST_FAILED', msg); - } - - return target; - } finally { - await this.releaseLock(lock); - } - } - - async upsertNodeState(name: string, nodeId: string, patch: Record): Promise { - const current = await this.get(name); - const base = current ?? ({ name, version: 0, updatedAt: new Date().toISOString(), nodes: [], edges: [] } as PersistedGraph); - const nodes = Array.from(base.nodes || []); - const idx = nodes.findIndex((n) => n.id === nodeId); - if (idx >= 0) nodes[idx] = { ...nodes[idx], state: patch } as PersistedGraphNode; - else nodes.push({ id: nodeId, template: 'unknown', state: patch } as PersistedGraphNode); - await this.upsert({ name, version: base.version, nodes, edges: base.edges }, undefined); - } - - private async persistGraph(_current: PersistedGraph, target: PersistedGraph): Promise { - const stagingDir = await this.createStagingTree(target); - try { - await this.swapWorkingTree(stagingDir); - } catch (err) { - await this.discardTempDir(stagingDir); - throw err; - } - } - - private async readFromWorkingTree(name: string): Promise { - const meta = await this.readMetaAt(this.absolutePath(this.metaPath()), name); - if (!meta) return null; - const nodesRes = await this.readEntitiesFromDir(this.absolutePath('nodes')); - const edgesRes = await this.readEntitiesFromDir(this.absolutePath('edges')); - if (nodesRes.hadError || edgesRes.hadError) { - throw new Error('Working tree read error'); - } - const variables = await this.readVariablesFromBase(this.ensureGraphRoot()); - return { - name: meta.name, - version: meta.version, - updatedAt: meta.updatedAt, - nodes: nodesRes.items, - edges: edgesRes.items, - variables, - }; - } - - private async createStagingTree(graph: PersistedGraph): Promise { - const stagingDir = this.tempDirPath(this.stagingDirPrefix()); - await fs.mkdir(path.dirname(stagingDir), { recursive: true }); - await fs.mkdir(stagingDir, { recursive: true }); - await fs.mkdir(path.join(stagingDir, 'nodes'), { recursive: true }); - await fs.mkdir(path.join(stagingDir, 'edges'), { recursive: true }); - - for (const node of graph.nodes) { - await this.writeYamlAtBase(stagingDir, this.nodePath(node.id), node); - } - for (const edge of graph.edges) { - await this.writeYamlAtBase(stagingDir, this.edgePath(edge.id!), edge); - } - await this.writeYamlAtBase(stagingDir, this.variablesPath(), graph.variables ?? []); - const meta: GraphMeta = { - name: graph.name, - version: graph.version, - updatedAt: graph.updatedAt, - format: 2, - }; - await this.writeYamlAtBase(stagingDir, this.metaPath(), meta); - - await this.syncDirectory(path.join(stagingDir, 'nodes')); - await this.syncDirectory(path.join(stagingDir, 'edges')); - await this.syncDirectory(stagingDir); - await this.syncDirectory(path.dirname(stagingDir)); - return stagingDir; - } - - private async swapWorkingTree(stagingDir: string): Promise { - const root = this.ensureGraphRoot(); - const parent = this.repoParentDir(); - const backupDir = this.tempDirPath(this.backupDirPrefix()); - let rootRenamed = false; - let newTreeActive = false; - try { - await fs.mkdir(parent, { recursive: true }); - await fs.rename(root, backupDir); - rootRenamed = true; - await fs.rename(stagingDir, root); - newTreeActive = true; - await this.restoreAuxiliaryEntries(backupDir, root); - await this.syncDirectory(parent); - } catch (err) { - if (newTreeActive) { - await fs.rm(root, { recursive: true, force: true }).catch(() => undefined); - } - if (rootRenamed) { - await fs.rename(backupDir, root).catch(() => undefined); - } - throw err; - } finally { - await this.discardTempDir(stagingDir); - await this.discardTempDir(backupDir); - } - } - - private stripInternalNode(node: PersistedGraphNode): PersistedGraphNode { - return { - id: node.id, - template: node.template, - config: node.config, - state: node.state, - position: node.position, - }; - } - - private stripInternalEdge(edge: PersistedGraphEdge): PersistedGraphEdge { - return { source: edge.source, sourceHandle: edge.sourceHandle, target: edge.target, targetHandle: edge.targetHandle, id: edge.id }; - } - - private nodePath(id: string): string { - return path.posix.join('nodes', `${encodeURIComponent(id)}.yaml`); - } - - private edgePath(id: string): string { - return path.posix.join('edges', `${encodeURIComponent(id)}.yaml`); - } - - private variablesPath(): string { - return 'variables.yaml'; - } - - private metaPath(): string { - return 'graph.meta.yaml'; - } - - private async writeYamlEntity(relPath: string, data: unknown): Promise { - await this.writeYamlAtBase(this.ensureGraphRoot(), relPath, data); - } - - private async writeYamlAtBase(baseDir: string, relPath: string, data: unknown): Promise { - const abs = path.join(baseDir, relPath); - await this.atomicWriteFile(abs, stringifyYaml(data)); - } - - private edgeId(edge: PersistedGraphEdge): string { - return `${edge.source}-${edge.sourceHandle}__${edge.target}-${edge.targetHandle}`; - } - - private async readEntitiesFromDir(dir: string): Promise<{ items: T[]; hadError: boolean }> { - const items: T[] = []; - let hadError = false; - let files: string[] = []; - try { - files = await fs.readdir(dir); - } catch { - return { items, hadError }; - } - for (const file of files) { - if (!file.endsWith('.yaml')) continue; - const abs = path.join(dir, file); - try { - const raw = await fs.readFile(abs, 'utf8'); - const record = parseYaml(raw) as Record; - const fallbackId = decodeURIComponent(file.replace(/\.yaml$/i, '')); - const candidateId = record?.id; - (record as Record).id = typeof candidateId === 'string' && candidateId.length > 0 ? candidateId : fallbackId; - items.push(record as unknown as T); - } catch { - hadError = true; - } - } - return { items, hadError }; - } - - private async readMetaAt(absPath: string, fallbackName: string): Promise { - try { - const parsed = parseYaml>(await fs.readFile(absPath, 'utf8')); - return this.normalizeMeta(parsed ?? {}, fallbackName); - } catch { - return null; - } - } - - private normalizeMeta(parsed: Partial, fallbackName: string): GraphMeta { - return { - name: (parsed.name ?? fallbackName) as string, - version: Number(parsed.version ?? 0), - updatedAt: (parsed.updatedAt ?? new Date().toISOString()) as string, - format: 2, - }; - } - - private async readVariablesFromBase(baseDir: string): Promise | undefined> { - const abs = path.join(baseDir, this.variablesPath()); - try { - const raw = await fs.readFile(abs, 'utf8'); - return this.normalizeVariables(parseYaml(raw)); - } catch { - return undefined; - } - } - - private normalizeVariables(raw: unknown): Array<{ key: string; value: string }> | undefined { - if (!Array.isArray(raw)) return undefined; - const out: Array<{ key: string; value: string }> = []; - for (const entry of raw) { - if (!entry || typeof entry !== 'object') continue; - const key = 'key' in entry ? String((entry as { key?: unknown }).key ?? '') : ''; - const value = 'value' in entry ? String((entry as { value?: unknown }).value ?? '') : ''; - if (!key) continue; - out.push({ key, value }); - } - return out.length ? out : undefined; - } - private async restoreAuxiliaryEntries(backupDir: string, root: string): Promise { - const preserve = ['.git']; - for (const entry of preserve) { - const source = path.join(backupDir, entry); - if (!(await this.pathExists(source))) continue; - const dest = path.join(root, entry); - await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined); - await fs.rename(source, dest).catch(() => undefined); - } - } - - private tempDirPath(prefix: string): string { - const parent = this.repoParentDir(); - const uniqueId = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`; - return path.join(parent, `${prefix}${uniqueId}`); - } - - private repoParentDir(): string { - return path.dirname(this.ensureGraphRoot()); - } - - private repoBaseName(): string { - const base = path.basename(this.ensureGraphRoot()).replace(/[^a-zA-Z0-9.-]/g, '_'); - return base.length ? base : 'graph'; - } - - private stagingDirPrefix(): string { - return `${STAGING_PREFIX}${this.repoBaseName()}-`; - } - - private backupDirPrefix(): string { - return `${BACKUP_PREFIX}${this.repoBaseName()}-`; - } - - private lockFilePath(): string { - return path.join(this.repoParentDir(), `.${this.repoBaseName()}.graph.lock`); - } - - private async discardTempDir(dir: string): Promise { - await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); - } - - private async cleanupSwapArtifacts(): Promise { - const root = this.ensureGraphRoot(); - const parent = this.repoParentDir(); - await fs.mkdir(parent, { recursive: true }); - let entries: Dirent[] = []; - try { - entries = await fs.readdir(parent, { withFileTypes: true }); - } catch { - return; - } - const stagingPrefix = this.stagingDirPrefix(); - const backupPrefix = this.backupDirPrefix(); - const stagingDirs = entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(stagingPrefix)); - const backupDirs = entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(backupPrefix)); - const rootExists = await this.pathExists(root); - if (!rootExists && backupDirs.length) { - const candidate = await this.selectNewestDir(parent, backupDirs); - if (candidate) { - await fs.rename(path.join(parent, candidate.name), root).catch(() => undefined); - const idx = backupDirs.findIndex((entry) => entry.name === candidate.name); - if (idx >= 0) backupDirs.splice(idx, 1); - } - } - await Promise.all(stagingDirs.map((entry) => this.discardTempDir(path.join(parent, entry.name)))); - await Promise.all(backupDirs.map((entry) => this.discardTempDir(path.join(parent, entry.name)))); - } - - private async selectNewestDir(base: string, entries: Dirent[]): Promise { - let newest: { entry: Dirent; mtime: number } | null = null; - for (const entry of entries) { - try { - const stat = await fs.stat(path.join(base, entry.name)); - if (!newest || stat.mtimeMs > newest.mtime) { - newest = { entry, mtime: stat.mtimeMs }; - } - } catch { - // ignore - } - } - return newest?.entry ?? null; - } - - private async syncDirectory(dir: string): Promise { - try { - const fd = await fs.open(dir, 'r'); - try { - await fd.sync(); - } finally { - await fd.close(); - } - } catch { - // ignore - } - } - - private async appendDefaultMeta(name: string): Promise { - const meta: GraphMeta = { name, version: 0, updatedAt: new Date().toISOString(), format: 2 }; - await this.writeYamlEntity(this.metaPath(), meta); - } - - private async restoreWorkingTree(graph: PersistedGraph | null): Promise { - try { - if (!graph) { - await this.clearDirectory(this.absolutePath('nodes')); - await this.clearDirectory(this.absolutePath('edges')); - await this.writeYamlEntity(this.variablesPath(), []); - await this.appendDefaultMeta('main'); - return; - } - await this.clearDirectory(this.absolutePath('nodes')); - await this.clearDirectory(this.absolutePath('edges')); - for (const node of graph.nodes) { - await this.writeYamlEntity(this.nodePath(node.id), node); - } - for (const edge of graph.edges) { - await this.writeYamlEntity(this.edgePath(edge.id!), edge); - } - await this.writeYamlEntity(this.variablesPath(), graph.variables ?? []); - await this.writeYamlEntity(this.metaPath(), { - name: graph.name, - version: graph.version, - updatedAt: graph.updatedAt, - format: 2, - } satisfies GraphMeta); - } catch { - // best-effort rollback - } - } - - private async clearDirectory(dir: string): Promise { - await fs.rm(dir, { recursive: true, force: true }); - await fs.mkdir(dir, { recursive: true }); - } - - private cloneGraph(graph: PersistedGraph): PersistedGraph { - return JSON.parse(JSON.stringify(graph)) as PersistedGraph; - } - - private ensureGraphRoot(): string { - if (!this.graphRoot) { - throw new Error('FsGraphRepository not initialized'); - } - return this.graphRoot; - } - - private absolutePath(relPath: string): string { - return path.join(this.ensureGraphRoot(), relPath); - } - - private assertReady(): void { - if (!this.graphRoot) { - throw new Error('FsGraphRepository used before initialization'); - } - } - - private async acquireLock(): Promise { - const lockPath = this.lockFilePath(); - await fs.mkdir(path.dirname(lockPath), { recursive: true }); - const timeout = this.config.graphLockTimeoutMs ?? 5000; - const start = Date.now(); - while (true) { - try { - const fd = await fs.open(lockPath, 'wx'); - await fd.writeFile(`${process.pid} ${new Date().toISOString()}\n`); - await fd.close(); - return { lockPath }; - } catch (err) { - const code = (err as NodeJS.ErrnoException)?.code; - if (code !== 'EEXIST' && code !== 'ENOENT') throw err; - if (Date.now() - start > timeout) throw codeError('LOCK_TIMEOUT', 'Lock timeout'); - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - } - - private async releaseLock(handle: LockHandle): Promise { - if (!handle) return; - try { - await fs.unlink(handle.lockPath); - } catch { - // ignore - } - } - - private async atomicWriteFile(filePath: string, content: string): Promise { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - const tmp = path.join(dir, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`); - const fd = await fs.open(tmp, 'w'); - try { - await fd.writeFile(content); - await fd.sync(); - } finally { - await fd.close(); - } - await fs.rename(tmp, filePath); - try { - const dfd = await fs.open(dir, 'r'); - try { - await dfd.sync(); - } finally { - await dfd.close(); - } - } catch { - // ignore - } - } - - private async pathExists(p: string): Promise { - try { - await fs.stat(p); - return true; - } catch { - return false; - } - } -} diff --git a/packages/platform-server/src/graph/graphSchema.validator.ts b/packages/platform-server/src/graph/graphSchema.validator.ts deleted file mode 100644 index 4d4f88b90..000000000 --- a/packages/platform-server/src/graph/graphSchema.validator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { PersistedGraphUpsertRequest, TemplateNodeSchema } from '../shared/types/graph.types'; - -export function validatePersistedGraph(req: PersistedGraphUpsertRequest, schema: TemplateNodeSchema[]): void { - const templateSet = new Set(schema.map((s) => s.name)); - const schemaMap = new Map(schema.map((s) => [s.name, s] as const)); - const nodeIds = new Set(); - for (const n of req.nodes) { - if (!n.id) throw new Error('Node missing id'); - if (nodeIds.has(n.id)) throw new Error(`Duplicate node id ${n.id}`); - nodeIds.add(n.id); - if (!templateSet.has(n.template)) throw new Error(`Unknown template ${n.template}`); - } - for (const e of req.edges) { - if (!nodeIds.has(e.source)) throw new Error(`Edge source missing node ${e.source}`); - if (!nodeIds.has(e.target)) throw new Error(`Edge target missing node ${e.target}`); - const sourceNode = req.nodes.find((n) => n.id === e.source)!; - const targetNode = req.nodes.find((n) => n.id === e.target)!; - const sourceSchema = schemaMap.get(sourceNode.template)!; - const targetSchema = schemaMap.get(targetNode.template)!; - if (!sourceSchema.sourcePorts.includes(e.sourceHandle)) { - throw new Error(`Invalid source handle ${e.sourceHandle} on template ${sourceNode.template}`); - } - if (!targetSchema.targetPorts.includes(e.targetHandle)) { - throw new Error(`Invalid target handle ${e.targetHandle} on template ${targetNode.template}`); - } - } - // Variables: enforce unique non-empty keys and values when provided - if (req.variables) { - const seen = new Set(); - for (const v of req.variables) { - const key = (v?.key ?? '').trim(); - const val = (v?.value ?? '').trim(); - if (!key) throw new Error('Variable missing key'); - if (!val) throw new Error(`Variable ${key} missing value`); - if (seen.has(key)) throw new Error(`Duplicate variable key ${key}`); - seen.add(key); - } - } -} diff --git a/packages/platform-server/src/graph/hybridGraph.repository.ts b/packages/platform-server/src/graph/hybridGraph.repository.ts deleted file mode 100644 index fe9ea6b69..000000000 --- a/packages/platform-server/src/graph/hybridGraph.repository.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - PersistedGraph, - PersistedGraphEdge, - PersistedGraphNode, - PersistedGraphUpsertRequest, - PersistedGraphUpsertResponse, -} from '../shared/types/graph.types'; -import type { GraphAuthor } from './graph.repository'; -import { GraphRepository } from './graph.repository'; -import { FsGraphRepository } from './fsGraph.repository'; -import { edgeKey } from './graph.utils'; -import type { TeamsGraphSnapshot } from './teamsGraph.source'; -import { TeamsGraphSource } from './teamsGraph.source'; - -export const TEAMS_MANAGED_TEMPLATES = new Set([ - 'agent', - 'manageTool', - 'memoryTool', - 'shellTool', - 'sendMessageTool', - 'sendSlackMessageTool', - 'remindMeTool', - 'githubCloneRepoTool', - 'callAgentTool', - 'workspace', - 'mcpServer', - 'memory', - 'memoryConnector', -]); - -export class HybridGraphRepository extends GraphRepository { - constructor( - private readonly fsRepo: FsGraphRepository, - private readonly teamsSource: TeamsGraphSource, - ) { - super(); - } - - async initIfNeeded(): Promise { - await this.fsRepo.initIfNeeded(); - } - - async get(name: string): Promise { - const [fsGraph, teamsGraph] = await Promise.all([this.fsRepo.get(name), this.teamsSource.load()]); - if (!fsGraph && teamsGraph.nodes.length === 0 && teamsGraph.edges.length === 0) { - return null; - } - const base: PersistedGraph = fsGraph ?? { - name, - version: 0, - updatedAt: new Date().toISOString(), - nodes: [], - edges: [], - variables: [], - }; - return this.mergeGraphs(base, teamsGraph); - } - - async upsert(req: PersistedGraphUpsertRequest, author?: GraphAuthor): Promise { - return this.fsRepo.upsert(req, author); - } - - async upsertNodeState(name: string, nodeId: string, patch: Record): Promise { - await this.fsRepo.upsertNodeState(name, nodeId, patch); - } - - private mergeGraphs(base: PersistedGraph, teamsGraph: TeamsGraphSnapshot): PersistedGraph { - const fsNodesById = new Map(base.nodes.map((node) => [node.id, node])); - const teamsNodeIds = new Set(teamsGraph.nodes.map((node) => node.id)); - const nonTeamsNodes = base.nodes.filter( - (node) => !teamsNodeIds.has(node.id) && !TEAMS_MANAGED_TEMPLATES.has(node.template), - ); - const mergedTeamsNodes = teamsGraph.nodes.map((node) => this.mergeNode(node, fsNodesById.get(node.id))); - const nodes = [...nonTeamsNodes, ...mergedTeamsNodes]; - const nodeIds = new Set(nodes.map((node) => node.id)); - const edges = this.mergeEdges(base.edges, teamsGraph.edges, nodeIds, teamsNodeIds); - return { - ...base, - nodes, - edges, - }; - } - - private mergeNode(teamsNode: PersistedGraphNode, fsNode?: PersistedGraphNode): PersistedGraphNode { - const merged: PersistedGraphNode = { ...teamsNode }; - if (merged.state === undefined && fsNode?.state !== undefined) { - merged.state = fsNode.state; - } - if (merged.position === undefined && fsNode?.position !== undefined) { - merged.position = fsNode.position; - } - return merged; - } - - private mergeEdges( - fsEdges: PersistedGraphEdge[], - teamsEdges: PersistedGraphEdge[], - nodeIds: Set, - teamsNodeIds: Set, - ): PersistedGraphEdge[] { - const edges: PersistedGraphEdge[] = []; - const seen = new Set(); - - const addEdge = (edge: PersistedGraphEdge): void => { - if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return; - if (!edge.sourceHandle || !edge.targetHandle) return; - const key = edgeKey(edge); - if (seen.has(key)) return; - seen.add(key); - edges.push({ ...edge, id: key }); - }; - - for (const edge of fsEdges) { - if (teamsNodeIds.has(edge.source) && teamsNodeIds.has(edge.target)) continue; - addEdge(edge); - } - - for (const edge of teamsEdges) { - addEdge(edge); - } - - return edges; - } - -} diff --git a/packages/platform-server/src/graph/index.ts b/packages/platform-server/src/graph/index.ts index 90c4d92b9..c69d32760 100644 --- a/packages/platform-server/src/graph/index.ts +++ b/packages/platform-server/src/graph/index.ts @@ -6,7 +6,6 @@ export * from '../graph-core/liveGraph.manager'; export * from './ports.types'; export * from './ports.registry'; export * from './graph.repository'; -export * from './fsGraph.repository'; -export * from './hybridGraph.repository'; +export * from './teamsGraph.repository'; export * from './teamsGraph.source'; export * from './graph-api.module'; diff --git a/packages/platform-server/src/graph/services/graphVariables.service.ts b/packages/platform-server/src/graph/services/graphVariables.service.ts index 78a4702cf..e05623263 100644 --- a/packages/platform-server/src/graph/services/graphVariables.service.ts +++ b/packages/platform-server/src/graph/services/graphVariables.service.ts @@ -1,25 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; import { PrismaService } from '../../core/services/prisma.service'; -import { GraphRepository } from '../graph.repository'; -import type { PersistedGraph } from '../../shared/types/graph.types'; export type VarItem = { key: string; graph: string | null; local: string | null }; // Service encapsulates business logic for graph variables operations @Injectable() export class GraphVariablesService { - constructor( - @Inject(GraphRepository) private readonly graphs: GraphRepository, - // Inject PrismaService directly (standard Nest DI) - @Inject(PrismaService) private readonly prismaService: PrismaService, - ) {} + constructor(@Inject(PrismaService) private readonly prismaService: PrismaService) {} - async list(name = 'main'): Promise<{ items: VarItem[] }> { - const graph = (await this.graphs.get(name)) || ({ name, version: 0, updatedAt: new Date().toISOString(), nodes: [], edges: [], variables: [] } as PersistedGraph); + async list(_name = 'main'): Promise<{ items: VarItem[] }> { const prisma = this.prismaService.getClient(); - const locals = await prisma.variableLocal.findMany(); + const [graphVars, locals] = await Promise.all([ + prisma.graphVariable.findMany(), + prisma.variableLocal.findMany(), + ]); const itemsMap = new Map(); - for (const v of graph.variables || []) itemsMap.set(v.key, { key: v.key, graph: v.value, local: null }); + for (const v of graphVars) { + itemsMap.set(v.key, { key: v.key, graph: v.value, local: null }); + } for (const lv of locals) { const existing = itemsMap.get(lv.key); if (existing) existing.local = lv.value; @@ -28,41 +26,23 @@ export class GraphVariablesService { return { items: Array.from(itemsMap.values()) }; } - async create(name: string, key: string, graphValue: string): Promise<{ key: string; graph: string }> { - const current = (await this.graphs.get(name)) || ({ name, version: 0, updatedAt: new Date().toISOString(), nodes: [], edges: [], variables: [] } as PersistedGraph); - const exists = (current.variables || []).some((v) => v.key === key); - if (exists) throw Object.assign(new Error('Duplicate key'), { code: 'DUPLICATE_KEY' }); - const next: PersistedGraph = { ...current, version: current.version, variables: [...(current.variables || []), { key, value: graphValue }] }; - try { - await this.graphs.upsert({ name, version: current.version, nodes: next.nodes, edges: next.edges, variables: next.variables }); - } catch (e: unknown) { - const code = e && typeof e === 'object' && 'code' in e ? (e as { code?: string }).code : undefined; - if (code === 'VERSION_CONFLICT') throw e; - throw e; - } + async create(_name: string, key: string, graphValue: string): Promise<{ key: string; graph: string }> { + const prisma = this.prismaService.getClient(); + const existing = await prisma.graphVariable.findUnique({ where: { key } }); + if (existing) throw Object.assign(new Error('Duplicate key'), { code: 'DUPLICATE_KEY' }); + await prisma.graphVariable.create({ data: { key, value: graphValue } }); return { key, graph: graphValue }; } - async update(name: string, key: string, req: { graph?: string | null; local?: string | null }): Promise<{ key: string; graph?: string | null; local?: string | null }> { - // Graph update + async update(_name: string, key: string, req: { graph?: string; local?: string | null }): Promise<{ key: string; graph?: string | null; local?: string | null }> { + const prisma = this.prismaService.getClient(); if (req.graph !== undefined) { - const current = await this.graphs.get(name); - if (!current) throw Object.assign(new Error('Graph not found'), { code: 'GRAPH_NOT_FOUND' }); - const idx = (current.variables || []).findIndex((v) => v.key === key); - if (idx < 0) throw Object.assign(new Error('Key not found'), { code: 'KEY_NOT_FOUND' }); - const variables = Array.from(current.variables || []); - variables[idx] = { key, value: req.graph! }; - try { - await this.graphs.upsert({ name, version: current.version, nodes: current.nodes, edges: current.edges, variables }); - } catch (e: unknown) { - const code = e && typeof e === 'object' && 'code' in e ? (e as { code?: string }).code : undefined; - if (code === 'VERSION_CONFLICT') throw e; - throw e; - } + const current = await prisma.graphVariable.findUnique({ where: { key } }); + if (!current) throw Object.assign(new Error('Key not found'), { code: 'KEY_NOT_FOUND' }); + await prisma.graphVariable.update({ where: { key }, data: { value: req.graph } }); } // Local override update if (req.local !== undefined) { - const prisma = this.prismaService.getClient(); const val = (req.local ?? '').trim(); if (!val) await prisma.variableLocal.deleteMany({ where: { key } }); else await prisma.variableLocal.upsert({ where: { key }, update: { value: val }, create: { key, value: val } }); @@ -76,33 +56,21 @@ export class GraphVariablesService { return out; } - async remove(name: string, key: string): Promise { - const current = await this.graphs.get(name); - if (current) { - const variables = (current.variables || []).filter((v) => v.key !== key); - try { - await this.graphs.upsert({ name, version: current.version, nodes: current.nodes, edges: current.edges, variables }); - } catch (e: unknown) { - const code = e && typeof e === 'object' && 'code' in e ? (e as { code?: string }).code : undefined; - if (code === 'VERSION_CONFLICT') throw e; - throw e; - } - } + async remove(_name: string, key: string): Promise { const prisma = this.prismaService.getClient(); + await prisma.graphVariable.deleteMany({ where: { key } }); await prisma.variableLocal.deleteMany({ where: { key } }); } - async resolveValue(graphName: string, key: string): Promise { + async resolveValue(_graphName: string, key: string): Promise { const prisma = this.prismaService.getClient(); const local = await prisma.variableLocal.findUnique({ where: { key } }); const localValue = local?.value ?? null; if (typeof localValue === 'string' && localValue.length > 0) return localValue; - const graph = await this.graphs.get(graphName); - if (!graph) return undefined; - const entry = (graph.variables || []).find((v) => v.key === key); - const graphValue = entry?.value ?? null; - if (typeof graphValue === 'string' && graphValue.length > 0) return graphValue; + const graphValue = await prisma.graphVariable.findUnique({ where: { key } }); + const value = graphValue?.value ?? null; + if (typeof value === 'string' && value.length > 0) return value; return undefined; } } diff --git a/packages/platform-server/src/graph/teamsGraph.repository.ts b/packages/platform-server/src/graph/teamsGraph.repository.ts new file mode 100644 index 000000000..cc31e58c4 --- /dev/null +++ b/packages/platform-server/src/graph/teamsGraph.repository.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PrismaService } from '../core/services/prisma.service'; +import type { + PersistedGraph, + PersistedGraphUpsertRequest, + PersistedGraphUpsertResponse, +} from '../shared/types/graph.types'; +import type { GraphAuthor } from './graph.repository'; +import { GraphRepository } from './graph.repository'; +import { TeamsGraphSource } from './teamsGraph.source'; + +type PersistedNodeState = Record; + +const readNodeState = (value: unknown): PersistedNodeState | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as PersistedNodeState; +}; + +@Injectable() +export class TeamsGraphRepository extends GraphRepository { + constructor( + @Inject(TeamsGraphSource) private readonly teamsSource: TeamsGraphSource, + @Inject(PrismaService) private readonly prismaService: PrismaService, + ) { + super(); + } + + async initIfNeeded(): Promise { + return; + } + + async get(name: string): Promise { + if (name !== 'main') return null; + const snapshot = await this.teamsSource.load(); + if (snapshot.nodes.length === 0 && snapshot.edges.length === 0) { + return null; + } + + const prisma = this.prismaService.getClient(); + const [states, variables] = await Promise.all([ + prisma.graphNodeState.findMany(), + prisma.graphVariable.findMany(), + ]); + + const stateByNodeId = new Map(); + for (const entry of states) { + const state = readNodeState(entry.state); + if (!state) continue; + stateByNodeId.set(entry.nodeId, state); + } + + const nodes = snapshot.nodes.map((node) => { + const state = stateByNodeId.get(node.id); + if (!state) return node; + return { ...node, state }; + }); + + return { + name, + version: 0, + updatedAt: new Date().toISOString(), + nodes, + edges: snapshot.edges, + variables: variables.map((variable) => ({ key: variable.key, value: variable.value })), + } satisfies PersistedGraph; + } + + async upsert( + _req: PersistedGraphUpsertRequest, + _author?: GraphAuthor, + ): Promise { + const err = new Error('Graph persistence is disabled') as Error & { code?: string }; + err.code = 'GRAPH_READ_ONLY'; + throw err; + } + + async upsertNodeState(_name: string, nodeId: string, patch: Record): Promise { + const prisma = this.prismaService.getClient(); + await prisma.graphNodeState.upsert({ + where: { nodeId }, + update: { state: patch }, + create: { nodeId, state: patch }, + }); + } +} diff --git a/packages/platform-server/src/graph/teamsGraph.source.ts b/packages/platform-server/src/graph/teamsGraph.source.ts index 93b355f8b..e5130815e 100644 --- a/packages/platform-server/src/graph/teamsGraph.source.ts +++ b/packages/platform-server/src/graph/teamsGraph.source.ts @@ -30,17 +30,6 @@ import { WorkspacePlatform, } from '../proto/gen/agynio/api/teams/v1/teams_pb'; -const TOOL_TYPES: ToolType[] = [ - ToolType.MANAGE, - ToolType.MEMORY, - ToolType.SHELL_COMMAND, - ToolType.SEND_MESSAGE, - ToolType.SEND_SLACK_MESSAGE, - ToolType.REMIND_ME, - ToolType.GITHUB_CLONE_REPO, - ToolType.CALL_AGENT, -]; - const TOOL_TYPE_TO_TEMPLATE: Record = { [ToolType.UNSPECIFIED]: undefined, [ToolType.MANAGE]: 'manageTool', @@ -216,16 +205,15 @@ export class TeamsGraphSource { } private async listAllTools(): Promise { + const items = await listAllPages((page, perPage) => + this.teams.listTools(create(ListToolsRequestSchema, { page, perPage })), + ); const collected = new Map(); - for (const type of TOOL_TYPES) { - const items = await listAllPages((page, perPage) => - this.teams.listTools(create(ListToolsRequestSchema, { type, page, perPage })), - ); - for (const tool of items) { - const id = this.normalizeId(tool.id); - if (!id || collected.has(id)) continue; - collected.set(id, tool); - } + for (const tool of items) { + if (!TOOL_TYPE_TO_TEMPLATE[tool.type]) continue; + const id = this.normalizeId(tool.id); + if (!id || collected.has(id)) continue; + collected.set(id, tool); } return Array.from(collected.values()); } diff --git a/packages/platform-server/src/graph/yaml.util.ts b/packages/platform-server/src/graph/yaml.util.ts deleted file mode 100644 index d26ae7e5b..000000000 --- a/packages/platform-server/src/graph/yaml.util.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { parse as parseYamlImpl, stringify as stringifyYamlImpl } from 'yaml'; - -export function parseYaml(text: string): T { - return parseYamlImpl(text) as T; -} - -export function stringifyYaml(input: unknown): string { - const out = stringifyYamlImpl(input, { - indent: 2, - sortMapEntries: false, - lineWidth: 0, - }); - return out.endsWith('\n') ? out : `${out}\n`; -} diff --git a/packages/platform-server/src/infra/container/runnerGrpc.client.ts b/packages/platform-server/src/infra/container/runnerGrpc.client.ts index 49c202bc2..3d9d82105 100644 --- a/packages/platform-server/src/infra/container/runnerGrpc.client.ts +++ b/packages/platform-server/src/infra/container/runnerGrpc.client.ts @@ -12,21 +12,20 @@ import { import { createGrpcTransport, type Http2SessionManager } from '@connectrpc/connect-node'; import { create } from '@bufbuild/protobuf'; import { Logger } from '@nestjs/common'; -import { - buildAuthHeaders, - ContainerHandle, - containerOptsToStartWorkloadRequest, - type ContainerInspectInfo, - type ContainerOpts, - type DockerEventFilters, - type ExecInspectInfo, - type ExecOptions, - type ExecResult, - type InteractiveExecOptions, - type InteractiveExecSession, - type LogsStreamOptions, - type LogsStreamSession, -} from '@agyn/docker-runner'; +import { ContainerHandle } from './container.handle'; +import { buildAuthHeaders } from './auth'; +import type { + ContainerInspectInfo, + ContainerOpts, + DockerEventFilters, + ExecInspectInfo, + ExecOptions, + ExecResult, + InteractiveExecOptions, + InteractiveExecSession, + LogsStreamOptions, + LogsStreamSession, +} from './dockerRunner.types'; import { CancelExecutionRequestSchema, ExecOptionsSchema, @@ -79,6 +78,7 @@ import type { GetWorkloadLabelsRequest, InspectWorkloadRequest, } from '../../proto/gen/agynio/api/runner/v1/runner_pb.js'; +import { containerOptsToStartWorkloadRequest } from './workload.grpc'; import { ExecIdleTimeoutError, ExecTimeoutError } from '../../utils/execTimeout'; import type { DockerClient } from './dockerClient.token'; @@ -462,6 +462,17 @@ export class RunnerGrpcClient implements DockerClient { const callOptions = this.buildCallOptions(undefined, abortController.signal, false); const responses: AsyncIterable = this.client.streamEvents(request, callOptions); + const emitStreamError = (error: DockerRunnerRequestError): void => { + try { + stream.emit('error', error); + } catch (emitError) { + this.logger.warn('Runner events stream error before listeners attached', { + error, + emitError, + }); + } + }; + const pump = async () => { try { for await (const response of responses) { @@ -473,19 +484,21 @@ export class RunnerGrpcClient implements DockerClient { continue; } if (event.case === 'error') { - stream.emit('error', this.runnerErrorToException(event.value, 'runner_events_error')); + emitStreamError(this.runnerErrorToException(event.value, 'runner_events_error')); } } } catch (error) { if (!abortController.signal.aborted) { - stream.emit('error', this.translateServiceError(error, { path })); + emitStreamError(this.translateServiceError(error, { path })); } } finally { stream.end(); } }; - void pump(); + setImmediate(() => { + void pump(); + }); stream.on('close', () => { abortController.abort(); diff --git a/packages/platform-server/src/nodes/nodes.module.ts b/packages/platform-server/src/nodes/nodes.module.ts index 4feded59d..63a5d7550 100644 --- a/packages/platform-server/src/nodes/nodes.module.ts +++ b/packages/platform-server/src/nodes/nodes.module.ts @@ -10,7 +10,6 @@ import { MemoryService } from './memory/memory.service'; import { MemoryNode } from './memory/memory.node'; import { MemoryConnectorNode } from './memoryConnector/memoryConnector.node'; import { AgentNode } from './agent/agent.node'; -import { SlackTrigger } from './slackTrigger/slackTrigger.node'; import { SlackAdapter } from '../messaging/slack/slack.adapter'; import { ThreadTransportService } from '../messaging/threadTransport.service'; import { LocalMCPServerNode } from './mcp'; @@ -53,7 +52,6 @@ class NodesTemplateRegistrar implements OnModuleInit { MemoryNode, MemoryConnectorNode, AgentNode, - SlackTrigger, LocalMCPServerNode, ManageToolNode, CallAgentNode, @@ -84,7 +82,6 @@ class NodesTemplateRegistrar implements OnModuleInit { MemoryNode, MemoryConnectorNode, AgentNode, - SlackTrigger, LocalMCPServerNode, ManageToolNode, CallAgentNode, diff --git a/packages/platform-server/src/nodes/slackTrigger/slackTrigger.node.ts b/packages/platform-server/src/nodes/slackTrigger/slackTrigger.node.ts deleted file mode 100644 index 839d6da4d..000000000 --- a/packages/platform-server/src/nodes/slackTrigger/slackTrigger.node.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { SocketModeClient } from '@slack/socket-mode'; -import { z } from 'zod'; -import { ReferenceResolverService } from '../../utils/reference-resolver.service'; -import { ResolveError } from '../../utils/references'; -import Node from '../base/Node'; -import { Inject, Injectable, Scope } from '@nestjs/common'; -import { BufferMessage } from '../agent/messagesBuffer'; -import { HumanMessage } from '@agyn/llm'; -import { stringify as YamlStringify } from 'yaml'; -import { AgentsPersistenceService } from '../../agents/agents.persistence.service'; -import { PrismaService } from '../../core/services/prisma.service'; -import { SlackAdapter } from '../../messaging/slack/slack.adapter'; -import { ChannelDescriptorSchema, type SendResult, type ChannelDescriptor } from '../../messaging/types'; -import { LiveGraphRuntime } from '../../graph-core/liveGraph.manager'; -import type { LiveNode } from '../../graph/liveGraph.types'; -import { TemplateRegistry } from '../../graph-core/templateRegistry'; -import { isAgentLiveNode } from '../../agents/agent-node.utils'; -import { SecretReferenceSchema, VariableReferenceSchema } from '../../utils/reference-schemas'; - -type TriggerHumanMessage = { - kind: 'human'; - content: string; - info?: { - user?: string; - channel?: string; - channel_type?: string; - thread_ts?: string; - client_msg_id?: string; - event_ts?: string; - }; -}; -type TriggerListener = { invoke: (thread: string, messages: BufferMessage[]) => Promise }; - -const SlackAppTokenSchema = z.union([ - z.string().min(1).startsWith('xapp-', { message: 'Slack app token must start with xapp-' }), - SecretReferenceSchema, - VariableReferenceSchema, -]); - -const SlackBotTokenSchema = z.union([ - z.string().min(1).startsWith('xoxb-', { message: 'Slack bot token must start with xoxb-' }), - SecretReferenceSchema, - VariableReferenceSchema, -]); - -export const SlackTriggerStaticConfigSchema = z - .object({ - app_token: SlackAppTokenSchema, - bot_token: SlackBotTokenSchema, - }) - .strict(); - -type SlackTriggerConfig = z.infer; - -@Injectable({ scope: Scope.TRANSIENT }) -export class SlackTrigger extends Node { - private client: SocketModeClient | null = null; - - private botToken: string | null = null; - private resolvedTokens: { app: string; bot: string } | null = null; - - constructor( - @Inject(ReferenceResolverService) - private readonly referenceResolver: ReferenceResolverService, - @Inject(AgentsPersistenceService) private readonly persistence: AgentsPersistenceService, - @Inject(PrismaService) private readonly prismaService: PrismaService, - @Inject(SlackAdapter) private readonly slackAdapter: SlackAdapter, - @Inject(LiveGraphRuntime) private readonly runtime: LiveGraphRuntime, - @Inject(TemplateRegistry) private readonly templateRegistry: TemplateRegistry, - ) { - super(); - } - - private resolveAssignedAgentNodeId(): string | null { - try { - const nodeId = this.nodeId; - const outbound = this.runtime.getOutboundNodeIds(nodeId); - if (outbound.length === 0) return null; - const liveNodes = new Map(this.runtime.getNodes().map((node) => [node.id, node])); - const agentCandidates = outbound - .map((id) => liveNodes.get(id)) - .filter((node): node is LiveNode => isAgentLiveNode(node, this.templateRegistry)); - if (agentCandidates.length === 0) return null; - agentCandidates.sort((a, b) => a.id.localeCompare(b.id)); - return agentCandidates[0]?.id ?? null; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.logger.error(`SlackTrigger.resolveAssignedAgentNodeId failed: ${msg}`); - return null; - } - } - - private ensureToken(value: unknown, expectedPrefix: string, fieldName: 'app_token' | 'bot_token'): string { - if (typeof value !== 'string' || value.length === 0) throw new Error(`Slack ${fieldName} is required`); - if (!value.startsWith(expectedPrefix)) { - const label = fieldName === 'bot_token' ? 'bot token' : 'app token'; - throw new Error(`Slack ${label} must start with ${expectedPrefix}`); - } - return value; - } - - private async resolveTokens(cfg: SlackTriggerConfig): Promise<{ app: string; bot: string }> { - try { - const { output } = await this.referenceResolver.resolve(cfg, { - basePath: '/slack', - strict: true, - }); - const app = this.ensureToken(output.app_token, 'xapp-', 'app_token'); - const bot = this.ensureToken(output.bot_token, 'xoxb-', 'bot_token'); - return { app, bot }; - } catch (err) { - if (err instanceof ResolveError) { - throw new Error(`Slack token resolution failed: ${err.message}`); - } - throw err; - } - } - - private async resolveAppToken(): Promise { - if (!this.resolvedTokens) throw new Error('SlackTrigger config not set'); - return this.resolvedTokens.app; - } - // Store config only; token resolution happens during provision - async setConfig(cfg: SlackTriggerConfig): Promise { - this.resolvedTokens = await this.resolveTokens(cfg); - await super.setConfig(cfg); - } - - private async ensureClient(): Promise { - this.logger.log('SlackTrigger.ensureClient: entering'); - if (this.client) return this.client; - const appToken = await this.resolveAppToken(); - const client = new SocketModeClient({ appToken, logLevel: undefined }); - - const SlackMessageEventSchema = z.object({ - type: z.literal('message'), - text: z.string().optional(), - user: z.string().optional(), - bot_id: z.string().optional(), - subtype: z.string().optional(), - channel: z.string().optional(), - channel_type: z.string().optional(), - ts: z.string().optional(), - thread_ts: z.string().optional(), - client_msg_id: z.string().optional(), - event_ts: z.string().optional(), - }); - type SlackEventCallbackBody = { type: 'event_callback'; event?: unknown }; - type SlackEventsApiBody = { type: 'events_api'; payload?: { event?: unknown } }; - type SlackMessageEnvelope = { - ack: () => Promise; - envelope_id: string; - body?: SlackEventCallbackBody | SlackEventsApiBody; - event?: unknown; - retry_num?: number; - retry_reason?: string; - accepts_response_payload?: boolean; - }; - - client.on('message', async (envelope: SlackMessageEnvelope) => { - try { - // Slack expects an ACK within 3 seconds; acknowledge immediately to avoid retries and treat downstream handling as at-most-once. - await envelope.ack(); - const rawEvent = - envelope.body?.type === 'event_callback' - ? envelope.body.event - : envelope.body?.type === 'events_api' - ? envelope.body.payload?.event - : envelope.event; - const parsedEvent = SlackMessageEventSchema.safeParse(rawEvent); - if (!parsedEvent.success) { - this.logger.warn(`SlackTrigger: received non-message event or invalid event: ${parsedEvent.error}`); - return; - } - - const event = parsedEvent.data; - if (event.bot_id) return; - if (typeof event.subtype === 'string') return; - const text = typeof event.text === 'string' ? event.text : ''; - if (!text.trim()) return; - const userPart = typeof event.user === 'string' && event.user ? event.user : 'slack'; - const rootTs = - typeof event.thread_ts === 'string' && event.thread_ts - ? event.thread_ts - : typeof event.ts === 'string' && event.ts - ? event.ts - : null; - const alias = rootTs ? `${userPart}_${rootTs}` : userPart; - const msg: TriggerHumanMessage = { - kind: 'human', - content: text, - info: { - user: event.user, - channel: event.channel, - channel_type: event.channel_type, - ...(rootTs ? { thread_ts: rootTs } : {}), - client_msg_id: event.client_msg_id, - event_ts: event.event_ts, - }, - }; - const threadId = await this.persistence.getOrCreateThreadByAlias('slack', alias, text, { - channelNodeId: this.nodeId, - }); - const assignedAgentNodeId = this.resolveAssignedAgentNodeId(); - if (assignedAgentNodeId) { - await this.persistence.ensureAssignedAgent(threadId, assignedAgentNodeId); - } - // Persist descriptor only when channel present and event is top-level (no thread_ts) - if (typeof event.channel === 'string' && event.channel) { - if (!event.thread_ts && rootTs) { - const descriptor: ChannelDescriptor = { - type: 'slack', - version: 1, - identifiers: { - channel: event.channel, - thread_ts: rootTs, - }, - meta: { - channel_type: event.channel_type, - client_msg_id: event.client_msg_id, - event_ts: event.event_ts, - }, - createdBy: 'SlackTrigger', - }; - await this.persistence.updateThreadChannelDescriptor(threadId, descriptor); - } - } else { - this.logger.warn( - `SlackTrigger: missing channel in Slack event; not persisting descriptor threadId=${threadId} alias=${alias}`, - ); - } - await this.notify(threadId, [msg]); - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - this.logger.error(`SlackTrigger handler error: ${errMessage}`); - } - }); - this.client = client; - return client; - } - - protected async doProvision(): Promise { - this.logger.log('SlackTrigger.doProvision: starting'); - // Resolve bot token during provision/setup only - try { - if (!this.resolvedTokens) throw new Error('SlackTrigger config not set'); - this.botToken = this.resolvedTokens.bot; - } catch (e) { - const msg = e instanceof Error && e.message ? e.message : 'invalid_or_missing_bot_token'; - this.logger.error(`SlackTrigger.doProvision: bot token resolution failed error=${msg}`); - this.setStatus('provisioning_error'); - throw new Error(msg); - } - const client = await this.ensureClient(); - this.logger.log('Starting SlackTrigger (socket mode)'); - try { - await client.start(); - this.logger.log('SlackTrigger started'); - } catch (e) { - const errMessage = e instanceof Error ? e.message : String(e); - this.logger.error(`SlackTrigger.start failed: ${errMessage}`); - this.setStatus('provisioning_error'); - throw e; - } - } - protected async doDeprovision(): Promise { - this.logger.log('SlackTrigger.doDeprovision: stopping'); - try { - await this.client?.disconnect(); - } catch (e) { - const errMessage = e instanceof Error ? e.message : String(e); - this.logger.error(`SlackTrigger.disconnect error: ${errMessage}`); - this.setStatus('deprovisioning_error'); - throw e; - } - this.client = null; - this.logger.log('SlackTrigger stopped'); - } - - private _listeners: TriggerListener[] = []; - async subscribe(listener: TriggerListener): Promise { - this._listeners.push(listener); - } - async unsubscribe(listener: TriggerListener): Promise { - this._listeners = this._listeners.filter((l) => l !== listener); - } - protected async notify(thread: string, messages: TriggerHumanMessage[]): Promise { - this.logger.debug(`[SlackTrigger.notify] thread=${thread} messages=${YamlStringify(messages)}`); - if (!messages.length) return; - await Promise.all( - this._listeners.map(async (listener) => - listener.invoke( - thread, - messages.map((m) => HumanMessage.fromText(`From User:\n${m.content}`)), - ), - ), - ); - } - - public listeners(): Array<(thread: string, messages: BufferMessage[]) => Promise> { - return this._listeners.map((l) => async (thread: string, messages: BufferMessage[]) => l.invoke(thread, messages)); - } - - getPortConfig() { - return { sourcePorts: { subscribe: { kind: 'method', create: 'subscribe', destroy: 'unsubscribe' } } } as const; - } - - // Send a text message using stored thread descriptor and this trigger's bot token - async sendToChannel(threadId: string, text: string): Promise { - try { - const prisma = this.prismaService.getClient(); - type ThreadChannelRow = { channel: unknown | null }; - const thread = (await prisma.thread.findUnique({ - where: { id: threadId }, - select: { channel: true }, - })) as ThreadChannelRow | null; - if (!thread) { - this.logger.error(`SlackTrigger.sendToChannel: missing descriptor threadId=${threadId}`); - return { ok: false, error: 'missing_channel_descriptor' }; - } - // Bot token must be set after provision/setup; do not resolve here - if (!this.botToken) { - this.logger.error('SlackTrigger.sendToChannel: trigger not provisioned'); - return { ok: false, error: 'slacktrigger_unprovisioned' }; - } - const channelRaw: unknown = thread.channel as unknown; - if (channelRaw == null) { - this.logger.error(`SlackTrigger.sendToChannel: missing descriptor threadId=${threadId}`); - return { ok: false, error: 'missing_channel_descriptor' }; - } - const parsed = ChannelDescriptorSchema.safeParse(channelRaw); - if (!parsed.success) { - this.logger.error(`SlackTrigger.sendToChannel: invalid descriptor threadId=${threadId}`); - return { ok: false, error: 'invalid_channel_descriptor' }; - } - const descriptor = parsed.data; - const ids = descriptor.identifiers; - const res = await this.slackAdapter.sendText({ - token: this.botToken!, - channel: ids.channel, - text, - thread_ts: ids.thread_ts, - }); - return res; - } catch (e) { - const msg = e instanceof Error && e.message ? e.message : 'unknown_error'; - this.logger.error(`SlackTrigger.sendToChannel failed threadId=${threadId} error=${msg}`); - return { ok: false, error: msg }; - } - } -} diff --git a/packages/platform-server/src/templates.ts b/packages/platform-server/src/templates.ts index 75b1fc907..2d9db51f8 100644 --- a/packages/platform-server/src/templates.ts +++ b/packages/platform-server/src/templates.ts @@ -6,7 +6,6 @@ import { WorkspaceNode } from './nodes/workspace/workspace.node'; import { LocalMCPServerNode } from './nodes/mcp/localMcpServer.node'; import { MemoryNode } from './nodes/memory/memory.node'; import { MemoryConnectorNode } from './nodes/memoryConnector/memoryConnector.node'; -import { SlackTrigger } from './nodes/slackTrigger/slackTrigger.node'; import { CallAgentTool } from './nodes/tools/call_agent/call_agent.node'; import { FinishTool } from './nodes/tools/finish/finish.node'; import { GithubCloneRepoNode } from './nodes/tools/github_clone_repo/github_clone_repo.node'; @@ -105,15 +104,6 @@ export function registerDefaultTemplates(registry: TemplateRegistry): TemplateRe }, RemindMeNode, ); - registry.register( - 'slackTrigger', - { - title: 'Slack (Socket Mode)', - kind: 'trigger', - // capabilities/staticConfigSchema removed from palette per Issue #451 - }, - SlackTrigger, - ); registry.register( 'agent', { diff --git a/packages/platform-ui/__tests__/components/configViews/SlackTriggerConfigView.test.tsx b/packages/platform-ui/__tests__/components/configViews/SlackTriggerConfigView.test.tsx deleted file mode 100644 index 1e978bf80..000000000 --- a/packages/platform-ui/__tests__/components/configViews/SlackTriggerConfigView.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React from 'react'; -import { render, screen, within, waitFor, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import SlackTriggerConfigView from '@/components/configViews/SlackTriggerConfigView'; - -vi.mock('@/features/secrets/utils/flatVault', () => ({ - listAllSecretPaths: () => Promise.resolve(['kv/prod/app', 'kv/prod/bot']), -})); - -vi.mock('@/features/variables/api', () => ({ - listVariables: () => - Promise.resolve([ - { key: 'SLACK_APP_TOKEN' }, - { key: 'SLACK_BOT_TOKEN' }, - ]), -})); - -const pointerProto = Element.prototype as unknown as { - hasPointerCapture?: (pointerId: number) => boolean; - setPointerCapture?: (pointerId: number) => void; - releasePointerCapture?: (pointerId: number) => void; -}; - -if (!pointerProto.hasPointerCapture) { - pointerProto.hasPointerCapture = () => false; -} -if (!pointerProto.setPointerCapture) { - pointerProto.setPointerCapture = () => {}; -} -if (!pointerProto.releasePointerCapture) { - pointerProto.releasePointerCapture = () => {}; -} -if (!Element.prototype.scrollIntoView) { - Element.prototype.scrollIntoView = () => {}; -} - -type SlackTriggerCfg = { - app_token?: unknown; - bot_token?: unknown; -}; - -describe('SlackTriggerConfigView', () => { - it('renders both app_token and bot_token fields and emits normalized shapes', async () => { - const user = userEvent.setup(); - let cfg: SlackTriggerCfg = {}; - render( - (cfg = v)} - readOnly={false} - disabled={false} - />, - ); - - // Ensure labels are present - expect(screen.getByText('App token')).toBeInTheDocument(); - expect(screen.getByText('Bot token')).toBeInTheDocument(); - - const appField = screen.getByText('App token').parentElement as HTMLElement; - const botField = screen.getByText('Bot token').parentElement as HTMLElement; - const appInput = within(appField).getAllByRole('textbox')[0]; - const botInput = within(botField).getAllByRole('textbox')[0]; - const appTrigger = within(appField).getAllByRole('combobox')[0]; - const botTrigger = within(botField).getAllByRole('combobox')[0]; - - // Set app token static value - await user.type(appInput, 'xapp-abc'); - // Set bot token static value - await user.type(botInput, 'xoxb-123'); - - expect(cfg.app_token).toBe('xapp-abc'); - expect(cfg.bot_token).toBe('xoxb-123'); - - // Switch bot token to vault and set mount/path/key - await user.click(botTrigger); - const botListbox = await screen.findByRole('listbox'); - const secretOption = within(botListbox).getByRole('option', { name: /secret/i }); - await user.click(secretOption); - await user.clear(botInput); - fireEvent.change(botInput, { target: { value: 'mount/path/key' } }); - expect(cfg.bot_token).toMatchObject({ kind: 'vault', mount: 'mount', path: 'path', key: 'key' }); - - // Ensure switching back to text works and placeholder updates automatically - await user.click(appTrigger); - const appListbox = await screen.findByRole('listbox'); - const textOption = within(appListbox).getByRole('option', { name: /plain text/i }); - await user.click(textOption); - await user.clear(appInput); - await user.type(appInput, 'xapp-xyz'); - expect(cfg.app_token).toBe('xapp-xyz'); - }); - - it('validates prefixes and vault refs', async () => { - const user = userEvent.setup(); - let errors: string[] = []; - const history: string[][] = []; - render( - {}} - readOnly={false} - disabled={false} - onValidate={(e) => { - errors = e; - history.push(e); - }} - />, - ); - - const appField = screen.getByText('App token').parentElement as HTMLElement; - const botField = screen.getByText('Bot token').parentElement as HTMLElement; - const appInput = within(appField).getAllByRole('textbox')[0]; - const botInput = within(botField).getAllByRole('textbox')[0]; - const appTrigger = within(appField).getAllByRole('combobox')[0]; - const botTrigger = within(botField).getAllByRole('combobox')[0]; - - // Invalid prefixes initially (empty) should report required errors once touched - await user.type(appInput, 'bad-app'); - await waitFor(() => { - expect(history.some((batch) => batch.includes('app_token must start with xapp-'))).toBe(true); - }); - - await user.type(botInput, 'bad-bot'); - await waitFor(() => { - expect(history.some((batch) => batch.includes('bot_token must start with xoxb-'))).toBe(true); - }); - - // Switch to vault and test regex - await user.click(appTrigger); - const appListbox = await screen.findByRole('listbox'); - const secretOption = within(appListbox).getByRole('option', { name: /secret/i }); - await user.click(secretOption); - await user.clear(appInput); - fireEvent.change(appInput, { target: { value: 'mount/app/TOKEN' } }); - await waitFor(() => { - expect(errors.some((e) => e.includes('app_token must start'))).toBe(false); - expect(errors.some((e) => e.includes('app_token vault ref'))).toBe(false); - }); - - await user.click(botTrigger); - const botListbox = await screen.findByRole('listbox'); - const botSecretOption = within(botListbox).getByRole('option', { name: /secret/i }); - await user.click(botSecretOption); - await user.clear(botInput); - fireEvent.change(botInput, { target: { value: 'bad' } }); - await waitFor(() => { - expect(history.some((batch) => batch.includes('bot_token vault ref must be mount/path/key'))).toBe(true); - }); - await user.clear(botInput); - fireEvent.change(botInput, { target: { value: 'm/p/k' } }); - await waitFor(() => { - expect(errors.includes('bot_token vault ref must be mount/path/key')).toBe(false); - }); - - // No masking behavior asserted (out of scope) - }); - - it('surfaces secret and variable suggestions after focus', async () => { - const user = userEvent.setup(); - render( - {}} - readOnly={false} - disabled={false} - />, - ); - - const appField = screen.getByText('App token').parentElement as HTMLElement; - const appInput = within(appField).getAllByRole('textbox')[0]; - const appTrigger = within(appField).getAllByRole('combobox')[0]; - - await user.click(appTrigger); - const appListbox = await screen.findByRole('listbox'); - const secretOption = within(appListbox).getByRole('option', { name: /secret/i }); - await user.click(secretOption); - - await user.click(appInput); - await screen.findByText('kv/prod/app'); - - const botField = screen.getByText('Bot token').parentElement as HTMLElement; - const botInput = within(botField).getAllByRole('textbox')[0]; - const botTrigger = within(botField).getAllByRole('combobox')[0]; - - await user.click(botTrigger); - const botListbox = await screen.findByRole('listbox'); - const variableOption = within(botListbox).getByRole('option', { name: /variable/i }); - await user.click(variableOption); - - await user.click(botInput); - await screen.findByText('SLACK_BOT_TOKEN'); - }); -}); diff --git a/packages/platform-ui/src/api/modules/graph.ts b/packages/platform-ui/src/api/modules/graph.ts index e74736721..53c836665 100644 --- a/packages/platform-ui/src/api/modules/graph.ts +++ b/packages/platform-ui/src/api/modules/graph.ts @@ -82,8 +82,10 @@ export const graph = { http.post(`/api/graph/nodes/${encodeURIComponent(nodeId)}/actions`, { action }), // Full graph - saveFullGraph: (g: PersistedGraphUpsertRequestUI) => - http.post(`/api/graph`, g), + saveFullGraph: (g: PersistedGraphUpsertRequestUI) => { + void g; + return http.get(`/api/graph`); + }, getFullGraph: () => http.get(`/api/graph`), }; diff --git a/packages/platform-ui/src/components/agents/GraphLayout.tsx b/packages/platform-ui/src/components/agents/GraphLayout.tsx index c904f4c57..0a94571e7 100644 --- a/packages/platform-ui/src/components/agents/GraphLayout.tsx +++ b/packages/platform-ui/src/components/agents/GraphLayout.tsx @@ -13,6 +13,7 @@ import { GraphCanvas, type GraphCanvasDropHandler, type GraphNodeData } from '.. import { GradientEdge } from './edges/GradientEdge'; import EmptySelectionSidebar from '../EmptySelectionSidebar'; import NodePropertiesSidebar, { type NodeConfig as SidebarNodeConfig } from '../NodePropertiesSidebar'; +import type { McpToolDescriptor } from '../nodeProperties/types'; import { resolveAgentDisplayTitle } from '../../utils/agentDisplay'; import { useGraphData } from '@/features/graph/hooks/useGraphData'; @@ -26,6 +27,7 @@ import type { GraphNodeConfig, GraphNodeStatus, GraphPersistedEdge } from '@/fea import type { TemplateSchema, NodeStatus as ApiNodeStatus } from '@/api/types/graph'; import { listAllSecretPaths } from '@/features/secrets/utils/flatVault'; import { getUuid } from '@/utils/getUuid'; +import { isRecord } from '@/utils/typeGuards'; type FlowNode = Node; @@ -118,6 +120,42 @@ function encodeHandle(handle?: string | null): string { return '$'; } +type McpStateSnapshot = { + tools: McpToolDescriptor[]; + enabledTools?: string[]; +}; + +function parseMcpTool(value: unknown): McpToolDescriptor | null { + if (!isRecord(value)) return null; + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!name) return null; + const tool: McpToolDescriptor = { name }; + if (typeof value.title === 'string') { + tool.title = value.title; + } + if (typeof value.description === 'string') { + tool.description = value.description; + } + return tool; +} + +function readMcpState(state: unknown): McpStateSnapshot { + if (!isRecord(state)) { + return { tools: [], enabledTools: undefined }; + } + const mcp = isRecord(state.mcp) ? (state.mcp as Record) : null; + if (!mcp) { + return { tools: [], enabledTools: undefined }; + } + const tools = Array.isArray(mcp.tools) + ? mcp.tools.map(parseMcpTool).filter((tool): tool is McpToolDescriptor => tool !== null) + : []; + const enabledTools = Array.isArray(mcp.enabledTools) + ? mcp.enabledTools.filter((tool): tool is string => typeof tool === 'string') + : undefined; + return { tools, enabledTools }; +} + function decodeHandle(handle?: string | null): string | undefined { if (!handle || handle === '$') { return undefined; @@ -405,7 +443,6 @@ export function GraphLayout({ services }: GraphLayoutProps) { const lastActionAtRef = useRef(0); const edgeTypes = useMemo(() => ({ gradient: GradientEdge }), []); - const fallbackEnabledTools = useMemo(() => [], []); const templatesQuery = useTemplates(); const sidebarNodeItems = useMemo(() => mapTemplatesToSidebarItems(templatesQuery.data), [templatesQuery.data]); const templatesByName = useMemo(() => { @@ -594,11 +631,19 @@ export function GraphLayout({ services }: GraphLayoutProps) { setEnabledTools: setMcpEnabledTools, isLoading: mcpToolsLoading, } = useMcpNodeState(mcpNodeId); + const fallbackMcpState = useMemo(() => { + if (!mcpNodeId) { + return { tools: [], enabledTools: undefined }; + } + return readMcpState(selectedNode?.state); + }, [mcpNodeId, selectedNode?.state]); + const resolvedMcpTools = mcpToolsLoading ? fallbackMcpState.tools : mcpTools; + const resolvedEnabledTools = mcpEnabledTools ?? (mcpToolsLoading ? fallbackMcpState.enabledTools : undefined); const handleToggleMcpTool = useCallback( (toolName: string, enabled: boolean) => { if (!mcpNodeId) return; - const current = mcpEnabledTools ?? []; + const current = resolvedEnabledTools ?? []; const next = new Set(current); if (enabled) { next.add(toolName); @@ -607,7 +652,7 @@ export function GraphLayout({ services }: GraphLayoutProps) { } setMcpEnabledTools(Array.from(next)); }, - [mcpEnabledTools, mcpNodeId, setMcpEnabledTools], + [mcpNodeId, resolvedEnabledTools, setMcpEnabledTools], ); const handleNodesChange = useCallback((changes: Parameters[0]) => { @@ -875,8 +920,8 @@ export function GraphLayout({ services }: GraphLayoutProps) { canProvision={canProvision} canDeprovision={canDeprovision} isActionPending={isActionPending} - tools={mcpTools} - enabledTools={mcpEnabledTools ?? fallbackEnabledTools} + tools={resolvedMcpTools} + enabledTools={resolvedEnabledTools} onToggleTool={handleToggleMcpTool} toolsLoading={mcpToolsLoading} nixPackageSearch={handleNixPackageSearch} diff --git a/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx b/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx index ceb7e4432..cf435124b 100644 --- a/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx +++ b/packages/platform-ui/src/components/entities/__tests__/EntityUpsertForm.test.tsx @@ -493,9 +493,6 @@ describe('EntityUpsertForm', () => { id: 'mcpServerWorkspace', selections: ['workspace-1'], attachmentKind: TEAM_ATTACHMENT_KIND.mcpServerWorkspaceConfiguration, -======= - attachmentKind: 'agent_memoryBucket', ->>>>>>> e9a06cd8 (fix(platform-ui): align team api contracts) }), ]), ); diff --git a/packages/platform-ui/src/components/nodeProperties/__tests__/NodePropertiesSidebar.trigger.test.tsx b/packages/platform-ui/src/components/nodeProperties/__tests__/NodePropertiesSidebar.trigger.test.tsx deleted file mode 100644 index 78287297a..000000000 --- a/packages/platform-ui/src/components/nodeProperties/__tests__/NodePropertiesSidebar.trigger.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useState } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import NodePropertiesSidebar from '../index'; -import type { NodeConfig, NodeState } from '../types'; - -if (!Element.prototype.hasPointerCapture) { - Element.prototype.hasPointerCapture = () => false; -} - -if (!Element.prototype.releasePointerCapture) { - Element.prototype.releasePointerCapture = () => {}; -} - -if (!Element.prototype.scrollIntoView) { - Element.prototype.scrollIntoView = () => {}; -} - -function renderTriggerSidebar(overrides?: Partial) { - const onConfigChange = vi.fn(); - const config: NodeConfig = { - kind: 'Trigger', - title: 'Slack trigger', - template: 'slackTrigger', - app_token: { kind: 'vault', mount: 'secret', path: 'slack', key: 'APP_TOKEN' }, - bot_token: { kind: 'var', name: 'SLACK_BOT_TOKEN' }, - ...(overrides as Record), - } as NodeConfig; - - const state: NodeState = { status: 'ready' } as NodeState; - - function Harness() { - const [currentConfig, setCurrentConfig] = useState(config); - const handleConfigChange = (patch: Partial) => { - setCurrentConfig((previous) => ({ ...previous, ...patch })); - onConfigChange(patch); - }; - - return ( - - ); - } - - render(); - - const appTokenInput = screen.getByPlaceholderText('Select or enter app token...') as HTMLInputElement; - const botTokenInput = screen.getByPlaceholderText('Select or enter bot token...') as HTMLInputElement; - - return { onConfigChange, appTokenInput, botTokenInput }; -} - -function latestUpdate(mock: ReturnType, key: string) { - for (let i = mock.mock.calls.length - 1; i >= 0; i -= 1) { - const payload = mock.mock.calls[i]?.[0] as Record; - if (payload && Object.prototype.hasOwnProperty.call(payload, key)) { - return payload[key]; - } - } - return undefined; -} - -describe('NodePropertiesSidebar Slack trigger references', () => { - it('loads canonical values, preserves round-trip, and supports mode switching', async () => { - const user = userEvent.setup(); - const { onConfigChange, appTokenInput, botTokenInput } = renderTriggerSidebar(); - - expect(appTokenInput).toHaveValue('secret/slack/APP_TOKEN'); - expect(botTokenInput).toHaveValue('SLACK_BOT_TOKEN'); - - onConfigChange.mockClear(); - await user.clear(appTokenInput); - await waitFor(() => expect(appTokenInput).toHaveValue('')); - fireEvent.change(appTokenInput, { target: { value: 'secret/slack/NEW_APP_TOKEN' } }); - await waitFor(() => expect(appTokenInput).toHaveValue('secret/slack/NEW_APP_TOKEN')); - - await waitFor(() => { - expect(latestUpdate(onConfigChange, 'app_token')).toEqual({ - kind: 'vault', - mount: 'secret', - path: 'slack', - key: 'NEW_APP_TOKEN', - }); - }); - - onConfigChange.mockClear(); - await user.clear(botTokenInput); - await waitFor(() => expect(botTokenInput).toHaveValue('')); - fireEvent.change(botTokenInput, { target: { value: 'SLACK_BOT_TOKEN_UPDATED' } }); - await waitFor(() => expect(botTokenInput).toHaveValue('SLACK_BOT_TOKEN_UPDATED')); - - await waitFor(() => { - expect(latestUpdate(onConfigChange, 'bot_token')).toEqual({ - kind: 'var', - name: 'SLACK_BOT_TOKEN_UPDATED', - }); - }); - - const [appSourceTrigger, botSourceTrigger] = screen.getAllByRole('combobox'); - - onConfigChange.mockClear(); - await user.click(appSourceTrigger); - const variableOption = await screen.findByText('Variable'); - await user.click(variableOption); - - await waitFor(() => { - expect(appTokenInput).toHaveValue(''); - expect(latestUpdate(onConfigChange, 'app_token')).toEqual({ kind: 'var', name: '' }); - }); - - onConfigChange.mockClear(); - fireEvent.change(appTokenInput, { target: { value: 'SLACK_APP_TOKEN_VAR' } }); - await waitFor(() => expect(appTokenInput).toHaveValue('SLACK_APP_TOKEN_VAR')); - - await waitFor(() => { - expect(latestUpdate(onConfigChange, 'app_token')).toEqual({ kind: 'var', name: 'SLACK_APP_TOKEN_VAR' }); - }); - - onConfigChange.mockClear(); - await user.click(botSourceTrigger); - const secretOption = await screen.findByText('Secret'); - await user.click(secretOption); - - await waitFor(() => { - expect(botTokenInput).toHaveValue(''); - expect(latestUpdate(onConfigChange, 'bot_token')).toEqual({ kind: 'vault', path: '', key: '' }); - }); - - onConfigChange.mockClear(); - fireEvent.change(botTokenInput, { target: { value: 'secret/slack/BOT_SECRET' } }); - await waitFor(() => expect(botTokenInput).toHaveValue('secret/slack/BOT_SECRET')); - - await waitFor(() => { - expect(latestUpdate(onConfigChange, 'bot_token')).toEqual({ - kind: 'vault', - mount: 'secret', - path: 'slack', - key: 'BOT_SECRET', - }); - }); - }); -}); diff --git a/packages/platform-ui/src/features/graph/services/api.ts b/packages/platform-ui/src/features/graph/services/api.ts index 04e0e4744..5081815ce 100644 --- a/packages/platform-ui/src/features/graph/services/api.ts +++ b/packages/platform-ui/src/features/graph/services/api.ts @@ -85,7 +85,8 @@ async function fetchGraph(): Promise { } async function saveGraph(payload: GraphSavePayload): Promise { - const response = await graphApi.saveFullGraph(payload); + void payload; + const response = await graphApi.getFullGraph(); return assertPersistedGraph(response); } diff --git a/packages/platform-ui/src/features/variables/__tests__/hooks.test.tsx b/packages/platform-ui/src/features/variables/__tests__/hooks.test.tsx index 469655752..7864350bd 100644 --- a/packages/platform-ui/src/features/variables/__tests__/hooks.test.tsx +++ b/packages/platform-ui/src/features/variables/__tests__/hooks.test.tsx @@ -115,15 +115,6 @@ describe('features/variables hooks', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['variables'] }); }); - it('surfaces delete version conflict error', async () => { - deleteVariableMock.mockRejectedValue(createApiError('VERSION_CONFLICT')); - const queryClient = new QueryClient(); - const { result } = renderHook(() => useDeleteVariable(), { wrapper: createWrapper(queryClient) }); - - await expect(result.current.mutateAsync('A')).rejects.toThrow('VERSION_CONFLICT'); - expect(notifyError).toHaveBeenCalledWith('Version conflict, please retry'); - }); - it('invalidates query after delete', async () => { deleteVariableMock.mockResolvedValue(undefined); const queryClient = new QueryClient(); diff --git a/packages/platform-ui/src/features/variables/hooks.ts b/packages/platform-ui/src/features/variables/hooks.ts index dc17ecb53..7e84c0ff2 100644 --- a/packages/platform-ui/src/features/variables/hooks.ts +++ b/packages/platform-ui/src/features/variables/hooks.ts @@ -37,8 +37,6 @@ export function useCreateVariable() { const code = extractErrorCode(error); if (code === 'DUPLICATE_KEY') { notifyError('Key already exists'); - } else if (code === 'VERSION_CONFLICT') { - notifyError('Version conflict, please retry'); } else { notifyError(code ?? 'Create failed'); } @@ -57,8 +55,6 @@ export function useUpdateVariable() { const code = extractErrorCode(error); if (code === 'BAD_VALUE') { notifyError('Value cannot be empty'); - } else if (code === 'VERSION_CONFLICT') { - notifyError('Version conflict, please retry'); } else { notifyError(code ?? 'Update failed'); } @@ -75,11 +71,7 @@ export function useDeleteVariable() { }, onError: (error: unknown) => { const code = extractErrorCode(error); - if (code === 'VERSION_CONFLICT') { - notifyError('Version conflict, please retry'); - } else { - notifyError(code ?? 'Delete failed'); - } + notifyError(code ?? 'Delete failed'); }, }); } diff --git a/packages/platform-ui/src/lib/graph/__tests__/normalize.test.ts b/packages/platform-ui/src/lib/graph/__tests__/normalize.test.ts index dfba3979f..5bb1be579 100644 --- a/packages/platform-ui/src/lib/graph/__tests__/normalize.test.ts +++ b/packages/platform-ui/src/lib/graph/__tests__/normalize.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach, type Mock } from 'vitest'; vi.mock('@/api/http', () => ({ http: { - post: vi.fn(), + get: vi.fn(), }, })); @@ -14,7 +14,7 @@ describe('graph.saveFullGraph', () => { vi.clearAllMocks(); }); - it('posts payload without mutating config shapes', async () => { + it('fetches snapshot without mutating config shapes', async () => { const payload = { name: 'sample', nodes: [ @@ -35,11 +35,11 @@ describe('graph.saveFullGraph', () => { edges: [], }; const snapshot = JSON.parse(JSON.stringify(payload)); - (http.post as unknown as Mock).mockResolvedValue({ ok: true }); + (http.get as unknown as Mock).mockResolvedValue({ ok: true }); await graph.saveFullGraph(payload as any); - expect(http.post).toHaveBeenCalledWith('/api/graph', payload); + expect(http.get).toHaveBeenCalledWith('/api/graph'); expect(payload).toEqual(snapshot); }); }); diff --git a/packages/platform-ui/src/lib/graph/hooks.ts b/packages/platform-ui/src/lib/graph/hooks.ts index 3b102b3b0..6fa510b1d 100644 --- a/packages/platform-ui/src/lib/graph/hooks.ts +++ b/packages/platform-ui/src/lib/graph/hooks.ts @@ -181,7 +181,10 @@ export function useDynamicConfig(nodeId: string) { // New: full graph save hook export function useSaveGraph() { return useMutation({ - mutationFn: (graph: PersistedGraphUpsertRequestUI) => api.saveFullGraph(graph), + mutationFn: async (graph: PersistedGraphUpsertRequestUI) => { + void graph; + return api.getFullGraph(); + }, onError: (err: unknown) => { const message = err instanceof Error ? err.message : String(err); notifyError(`Save graph failed: ${message}`); From e7941b17909e04ad4158a43b9d1c1a02dcdcba54 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sun, 15 Mar 2026 01:52:51 +0000 Subject: [PATCH 34/43] fix(docker-runner): restore sources --- packages/docker-runner/src/contracts/api.ts | 39 ++ packages/docker-runner/src/contracts/auth.ts | 165 +++++++ packages/docker-runner/src/contracts/json.ts | 23 + .../src/contracts/workload.grpc.ts | 416 ++++++++++++++++++ packages/docker-runner/src/index.ts | 12 + .../docker-runner/src/lib/container.handle.ts | 30 ++ .../docker-runner/src/lib/container.mounts.ts | 35 ++ .../src/lib/containerRegistry.port.ts | 19 + .../src/lib/containerStream.util.ts | 104 +++++ .../src/lib/dockerClient.port.ts | 42 ++ packages/docker-runner/src/lib/execTimeout.ts | 51 +++ packages/docker-runner/src/lib/types.ts | 123 ++++++ packages/docker-runner/src/service/config.ts | 40 ++ .../src/service/dockerEvents.parser.ts | 70 +++ packages/docker-runner/src/service/env.ts | 36 ++ .../docker-runner/src/service/grpc/server.ts | 4 +- packages/docker-runner/src/service/main.ts | 2 +- packages/docker-runner/tsconfig.eslint.json | 7 + packages/docker-runner/tsconfig.json | 15 + 19 files changed, 1230 insertions(+), 3 deletions(-) create mode 100644 packages/docker-runner/src/contracts/api.ts create mode 100644 packages/docker-runner/src/contracts/auth.ts create mode 100644 packages/docker-runner/src/contracts/json.ts create mode 100644 packages/docker-runner/src/contracts/workload.grpc.ts create mode 100644 packages/docker-runner/src/index.ts create mode 100644 packages/docker-runner/src/lib/container.handle.ts create mode 100644 packages/docker-runner/src/lib/container.mounts.ts create mode 100644 packages/docker-runner/src/lib/containerRegistry.port.ts create mode 100644 packages/docker-runner/src/lib/containerStream.util.ts create mode 100644 packages/docker-runner/src/lib/dockerClient.port.ts create mode 100644 packages/docker-runner/src/lib/execTimeout.ts create mode 100644 packages/docker-runner/src/lib/types.ts create mode 100644 packages/docker-runner/src/service/config.ts create mode 100644 packages/docker-runner/src/service/dockerEvents.parser.ts create mode 100644 packages/docker-runner/src/service/env.ts create mode 100644 packages/docker-runner/tsconfig.eslint.json create mode 100644 packages/docker-runner/tsconfig.json diff --git a/packages/docker-runner/src/contracts/api.ts b/packages/docker-runner/src/contracts/api.ts new file mode 100644 index 000000000..22e83b4d7 --- /dev/null +++ b/packages/docker-runner/src/contracts/api.ts @@ -0,0 +1,39 @@ +import type { ContainerInspectInfo, ContainerOpts, ExecOptions, ExecResult, Platform } from '../lib/types'; + +export type ErrorPayload = { + error: { + code: string; + message: string; + details?: Record; + retryable?: boolean; + }; +}; + +export type EnsureImageRequest = { image: string; platform?: Platform }; +export type StartContainerRequest = ContainerOpts; +export type StartContainerResponse = { containerId: string; name?: string; status?: string }; +export type StopContainerRequest = { containerId: string; timeoutSec?: number }; +export type RemoveContainerRequest = { + containerId: string; + force?: boolean; + removeVolumes?: boolean; +}; +export type InspectContainerResponse = ContainerInspectInfo; +export type FindByLabelsRequest = { labels: Record; all?: boolean }; +export type FindByLabelsResponse = { containerIds: string[] }; +export type ExecRunRequest = { containerId: string; command: string[] | string; options?: ExecOptions }; +export type ExecRunResponse = ExecResult; +export type ResizeExecRequest = { execId: string; size: { cols: number; rows: number } }; +export type LogsStreamQuery = { + containerId: string; + follow?: boolean; + since?: number; + tail?: number; + stdout?: boolean; + stderr?: boolean; + timestamps?: boolean; +}; +export type TouchRequest = { containerId: string }; +export type PutArchiveRequest = { containerId: string; path: string; payloadBase64: string }; +export type ListByVolumeResponse = { containerIds: string[] }; +export type RemoveVolumeRequest = { volumeName: string; force?: boolean }; diff --git a/packages/docker-runner/src/contracts/auth.ts b/packages/docker-runner/src/contracts/auth.ts new file mode 100644 index 000000000..8dbebc5e0 --- /dev/null +++ b/packages/docker-runner/src/contracts/auth.ts @@ -0,0 +1,165 @@ +import crypto from 'node:crypto'; +import { canonicalJsonStringify } from './json'; + +export type SignatureHeaders = { + timestamp: string; + nonce: string; + signature: string; +}; + +const HEADER_TIMESTAMP = 'x-dr-timestamp'; +const HEADER_NONCE = 'x-dr-nonce'; +const HEADER_SIGNATURE = 'x-dr-signature'; + +export const REQUIRED_HEADERS = [HEADER_TIMESTAMP, HEADER_NONCE, HEADER_SIGNATURE]; + +export function hashBody(body: string | Buffer): string { + const data = typeof body === 'string' ? Buffer.from(body) : body; + return crypto.createHash('sha256').update(data).digest('base64'); +} + +export function buildSignaturePayload(parts: { + method: string; + path: string; + timestamp: string; + nonce: string; + bodyHash: string; +}): string { + return `${parts.method.toUpperCase()}\n${parts.path}\n${parts.timestamp}\n${parts.nonce}\n${parts.bodyHash}`; +} + +export function signPayload(secret: string, payload: string): string { + return crypto.createHmac('sha256', secret).update(payload).digest('base64'); +} + +export function canonicalBodyString(body: unknown): string { + if (body === undefined || body === null || body === '') return ''; + if (typeof body === 'string') return body; + return canonicalJsonStringify(body); +} + +export type NonceCacheOptions = { + ttlMs?: number; + maxEntries?: number; +}; + +export class NonceCache { + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly store = new Map(); + + constructor(options: NonceCacheOptions = {}) { + this.ttlMs = typeof options.ttlMs === 'number' ? options.ttlMs : 60_000; + this.maxEntries = typeof options.maxEntries === 'number' ? options.maxEntries : 1000; + } + + has(nonce: string): boolean { + this.evictExpired(); + return this.store.has(nonce); + } + + add(nonce: string): void { + this.evictExpired(); + if (this.store.size >= this.maxEntries) { + const [firstKey] = this.store.keys(); + if (firstKey) this.store.delete(firstKey); + } + this.store.set(nonce, Date.now()); + } + + private evictExpired(): void { + const now = Date.now(); + for (const [nonce, ts] of this.store.entries()) { + if (now - ts > this.ttlMs) this.store.delete(nonce); + } + } +} + +export type BuildHeadersInput = { + method: string; + path: string; + body?: unknown; + secret: string; + timestamp?: number; + nonce?: string; +}; + +export function buildAuthHeaders(input: BuildHeadersInput): Record { + const timestamp = (input.timestamp ?? Date.now()).toString(); + const nonce = input.nonce ?? crypto.randomUUID(); + const bodyString = canonicalBodyString(input.body ?? ''); + const bodyHash = hashBody(bodyString); + const payload = buildSignaturePayload({ + method: input.method, + path: input.path, + timestamp, + nonce, + bodyHash, + }); + const signature = signPayload(input.secret, payload); + return { + [HEADER_TIMESTAMP]: timestamp, + [HEADER_NONCE]: nonce, + [HEADER_SIGNATURE]: signature, + }; +} + +export type VerifyHeadersInput = { + headers: Record; + method: string; + path: string; + body?: unknown; + secret: string; + clockSkewMs?: number; + nonceCache: NonceCache; +}; + +export function extractHeader(headers: VerifyHeadersInput['headers'], name: string): string | undefined { + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0]; + return value as string | undefined; +} + +export function verifyAuthHeaders(input: VerifyHeadersInput): { ok: boolean; code?: string; message?: string } { + const clockSkewMs = typeof input.clockSkewMs === 'number' ? input.clockSkewMs : 60_000; + const timestampStr = extractHeader(input.headers, HEADER_TIMESTAMP); + const nonce = extractHeader(input.headers, HEADER_NONCE); + const signature = extractHeader(input.headers, HEADER_SIGNATURE); + if (!timestampStr || !nonce || !signature) { + return { ok: false, code: 'missing_headers', message: 'Authentication headers missing' }; + } + const timestampNum = Number(timestampStr); + if (!Number.isFinite(timestampNum)) { + return { ok: false, code: 'invalid_timestamp', message: 'Timestamp invalid' }; + } + const now = Date.now(); + if (Math.abs(now - timestampNum) > clockSkewMs) { + return { ok: false, code: 'timestamp_out_of_range', message: 'Timestamp outside allowed skew' }; + } + if (input.nonceCache.has(nonce)) { + return { ok: false, code: 'replayed_nonce', message: 'Nonce already used' }; + } + const bodyString = canonicalBodyString(input.body ?? ''); + const bodyHash = hashBody(bodyString); + const payload = buildSignaturePayload({ + method: input.method, + path: input.path, + timestamp: timestampStr, + nonce, + bodyHash, + }); + const expectedSignature = signPayload(input.secret, payload); + if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { + return { ok: false, code: 'invalid_signature', message: 'Signature mismatch' }; + } + input.nonceCache.add(nonce); + return { ok: true }; +} + +export function headerNames() { + return { + timestamp: HEADER_TIMESTAMP, + nonce: HEADER_NONCE, + signature: HEADER_SIGNATURE, + }; +} diff --git a/packages/docker-runner/src/contracts/json.ts b/packages/docker-runner/src/contracts/json.ts new file mode 100644 index 000000000..7f7c22b58 --- /dev/null +++ b/packages/docker-runner/src/contracts/json.ts @@ -0,0 +1,23 @@ +const isBufferLike = (val: unknown): val is Buffer | Uint8Array => + typeof Buffer !== 'undefined' && (Buffer.isBuffer(val) || val instanceof Uint8Array); + +export function canonicalize(value: unknown): unknown { + if (value instanceof Date) return value.toISOString(); + if (isBufferLike(value)) return Buffer.from(value).toString('base64'); + if (Array.isArray(value)) { + return value.map((item) => canonicalize(item)); + } + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record); + return Object.fromEntries( + entries + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, val]) => [key, canonicalize(val)]), + ); + } + return value; +} + +export function canonicalJsonStringify(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} diff --git a/packages/docker-runner/src/contracts/workload.grpc.ts b/packages/docker-runner/src/contracts/workload.grpc.ts new file mode 100644 index 000000000..74832694d --- /dev/null +++ b/packages/docker-runner/src/contracts/workload.grpc.ts @@ -0,0 +1,416 @@ +import { create } from '@bufbuild/protobuf'; +import { + ContainerSpec, + ContainerSpecSchema, + EnvVar, + EnvVarSchema, + StartWorkloadRequest, + StartWorkloadRequestSchema, + VolumeKind, + VolumeMount, + VolumeMountSchema, + VolumeSpec, + VolumeSpecSchema, +} from '../proto/gen/agynio/api/runner/v1/runner_pb.js'; +import type { ContainerOpts, Platform, SidecarOpts } from '../lib/types'; + +const PROP_AUTO_REMOVE = 'auto_remove'; +const PROP_NETWORK_MODE = 'network_mode'; +const PROP_TTY = 'tty'; +const PROP_PRIVILEGED = 'privileged'; +const PROP_LABELS_JSON = 'labels_json'; +const PROP_CREATE_EXTRAS_JSON = 'create_extras_json'; +const PROP_BIND_OPTIONS = 'bind_options'; +const PROP_TTL_SECONDS = 'ttl_seconds'; +const PROP_PLATFORM = 'platform'; + +const BOOL_TRUE = new Set(['1', 'true', 'yes', 'on']); + +const isNonEmptyString = (value: string | undefined | null): value is string => typeof value === 'string' && value.length > 0; + +const parseBool = (value?: string): boolean | undefined => { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return undefined; + if (BOOL_TRUE.has(normalized)) return true; + if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false; + return undefined; +}; + +const parseIntSafe = (value?: string): number | undefined => { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const normalizeEnv = (env?: ContainerOpts['env']): EnvVar[] => { + if (!env) return []; + if (Array.isArray(env)) { + return env + .map((entry: string) => { + const idx = entry.indexOf('='); + if (idx === -1) return create(EnvVarSchema, { name: entry, value: '' }); + return create(EnvVarSchema, { name: entry.slice(0, idx), value: entry.slice(idx + 1) }); + }) + .filter((item) => item.name.length > 0); + } + return Object.entries(env) + .map(([name, value]: [string, unknown]) => create(EnvVarSchema, { name, value: String(value) })) + .filter((item: EnvVar) => item.name.length > 0); +}; + +const composeEnvRecord = (env: EnvVar[]): Record | undefined => { + if (!env.length) return undefined; + const record: Record = {}; + for (const variable of env) { + if (!isNonEmptyString(variable.name)) continue; + record[variable.name] = variable.value ?? ''; + } + return Object.keys(record).length > 0 ? record : undefined; +}; + +const parseBind = ( + bind: string, +): { source: string; destination: string; options: string[] } | null => { + if (!bind || typeof bind !== 'string') return null; + const segments = bind.split(':'); + if (segments.length < 2) return null; + const source = segments[0] ?? ''; + const destination = segments[1] ?? ''; + const optionsRaw = segments.slice(2).join(':'); + const options = optionsRaw + ? optionsRaw + .split(',') + .map((opt: string) => opt.trim()) + .filter((opt: string) => opt.length > 0) + : []; + if (!destination) return null; + return { source, destination, options }; +}; + +const composeBindString = ( + source: string, + destination: string, + options: string[], +): string => { + const normalizedOptions = options.filter( + (opt: string, index: number, arr: string[]) => opt.length > 0 && arr.indexOf(opt) === index, + ); + if (normalizedOptions.length === 0) return `${source}:${destination}`; + return `${source}:${destination}:${normalizedOptions.join(',')}`; +}; + +const ensureVolumeSpecName = (prefix: string, index: number): string => `${prefix}-${index}`; + +const cloneAdditionalProperties = (input?: Record): Record => ({ ...(input ?? {}) }); + +export const workloadContainerPropKeys = { + autoRemove: PROP_AUTO_REMOVE, + networkMode: PROP_NETWORK_MODE, + tty: PROP_TTY, + privileged: PROP_PRIVILEGED, + labelsJson: PROP_LABELS_JSON, + createExtrasJson: PROP_CREATE_EXTRAS_JSON, + bindOptions: PROP_BIND_OPTIONS, +} as const; + +export const workloadRequestPropKeys = { + ttlSeconds: PROP_TTL_SECONDS, + platform: PROP_PLATFORM, +} as const; + +export const containerOptsToStartWorkloadRequest = (opts: ContainerOpts): StartWorkloadRequest => { + const additionalContainerProps: Record = {}; + if (typeof opts.autoRemove === 'boolean') additionalContainerProps[PROP_AUTO_REMOVE] = String(opts.autoRemove); + if (typeof opts.networkMode === 'string') additionalContainerProps[PROP_NETWORK_MODE] = opts.networkMode; + if (typeof opts.tty === 'boolean') additionalContainerProps[PROP_TTY] = String(opts.tty); + if (typeof opts.privileged === 'boolean') additionalContainerProps[PROP_PRIVILEGED] = String(opts.privileged); + if (opts.labels) additionalContainerProps[PROP_LABELS_JSON] = JSON.stringify(opts.labels); + if (opts.createExtras) additionalContainerProps[PROP_CREATE_EXTRAS_JSON] = JSON.stringify(opts.createExtras); + + const volumes: VolumeSpec[] = []; + const mainMounts: VolumeMount[] = []; + let volumeIndex = 0; + + const registerVolume = ( + spec: VolumeSpec, + mount: VolumeMount, + target: VolumeMount[], + ) => { + volumes.push(spec); + target.push(mount); + }; + + if (Array.isArray(opts.binds)) { + for (const bind of opts.binds) { + const parsed = parseBind(bind); + if (!parsed) continue; + const name = ensureVolumeSpecName('bind', ++volumeIndex); + const isReadOnly = parsed.options.includes('ro'); + const spec = create(VolumeSpecSchema, { + name, + kind: VolumeKind.NAMED, + persistentName: parsed.source, + additionalProperties: + parsed.options.length > 0 ? { [PROP_BIND_OPTIONS]: parsed.options.join(',') } : {}, + }); + const mount = create(VolumeMountSchema, { + volume: name, + mountPath: parsed.destination, + readOnly: isReadOnly, + }); + registerVolume(spec, mount, mainMounts); + } + } + + if (Array.isArray(opts.anonymousVolumes)) { + for (const path of opts.anonymousVolumes) { + if (!isNonEmptyString(path)) continue; + const name = ensureVolumeSpecName('ephemeral', ++volumeIndex); + const spec = create(VolumeSpecSchema, { + name, + kind: VolumeKind.EPHEMERAL, + persistentName: '', + additionalProperties: {}, + }); + const mount = create(VolumeMountSchema, { + volume: name, + mountPath: path, + readOnly: false, + }); + registerVolume(spec, mount, mainMounts); + } + } + + const main = create(ContainerSpecSchema, { + image: opts.image ?? '', + name: opts.name ?? '', + cmd: opts.cmd ?? [], + entrypoint: opts.entrypoint ?? '', + env: normalizeEnv(opts.env), + workingDir: opts.workingDir ?? '', + mounts: mainMounts, + requiredCapabilities: opts.privileged ? ['privileged'] : [], + additionalProperties: additionalContainerProps, + }); + + const mapSidecar = (sidecar: SidecarOpts | undefined): ContainerSpec | undefined => { + if (!sidecar) return undefined; + const sidecarProps: Record = {}; + if (typeof sidecar.autoRemove === 'boolean') sidecarProps[PROP_AUTO_REMOVE] = String(sidecar.autoRemove); + if (typeof sidecar.networkMode === 'string') sidecarProps[PROP_NETWORK_MODE] = sidecar.networkMode; + if (typeof sidecar.privileged === 'boolean') sidecarProps[PROP_PRIVILEGED] = String(sidecar.privileged); + if (sidecar.labels) sidecarProps[PROP_LABELS_JSON] = JSON.stringify(sidecar.labels); + if (sidecar.createExtras) sidecarProps[PROP_CREATE_EXTRAS_JSON] = JSON.stringify(sidecar.createExtras); + + const mounts: VolumeMount[] = []; + if (Array.isArray(sidecar.anonymousVolumes)) { + for (const path of sidecar.anonymousVolumes) { + if (!isNonEmptyString(path)) continue; + const name = ensureVolumeSpecName('ephemeral', ++volumeIndex); + const spec = create(VolumeSpecSchema, { + name, + kind: VolumeKind.EPHEMERAL, + persistentName: '', + additionalProperties: {}, + }); + const mount = create(VolumeMountSchema, { + volume: name, + mountPath: path, + readOnly: false, + }); + registerVolume(spec, mount, mounts); + } + } + + return create(ContainerSpecSchema, { + image: sidecar.image ?? '', + name: '', + cmd: sidecar.cmd ?? [], + entrypoint: '', + env: normalizeEnv(sidecar.env), + workingDir: '', + mounts, + requiredCapabilities: sidecar.privileged ? ['privileged'] : [], + additionalProperties: sidecarProps, + }); + }; + + const sidecars = Array.isArray(opts.sidecars) + ? opts.sidecars + .map((sidecar: SidecarOpts) => mapSidecar(sidecar)) + .filter((spec): spec is ContainerSpec => spec !== undefined) + : []; + + const requestAdditional: Record = {}; + if (typeof opts.ttlSeconds === 'number' && Number.isFinite(opts.ttlSeconds)) { + requestAdditional[PROP_TTL_SECONDS] = String(Math.trunc(opts.ttlSeconds)); + } + if (opts.platform) { + requestAdditional[PROP_PLATFORM] = opts.platform; + } + + return create(StartWorkloadRequestSchema, { + main, + sidecars, + volumes, + additionalProperties: requestAdditional, + }); +}; + +export const startWorkloadRequestToContainerOpts = (request: StartWorkloadRequest): ContainerOpts => { + const main = request.main as ContainerSpec | undefined; + if (!main) throw new Error('main_container_spec_required'); + + const opts: ContainerOpts = {}; + + if (isNonEmptyString(main.image)) opts.image = main.image; + if (isNonEmptyString(main.name)) opts.name = main.name; + if (Array.isArray(main.cmd) && main.cmd.length > 0) opts.cmd = [...main.cmd]; + if (isNonEmptyString(main.entrypoint)) opts.entrypoint = main.entrypoint; + if (isNonEmptyString(main.workingDir)) opts.workingDir = main.workingDir; + + const envRecord = composeEnvRecord(main.env ?? []); + if (envRecord) opts.env = envRecord; + + const containerProps = cloneAdditionalProperties(main.additionalProperties); + const autoRemove = parseBool(containerProps[PROP_AUTO_REMOVE]); + if (typeof autoRemove === 'boolean') opts.autoRemove = autoRemove; + + if (isNonEmptyString(containerProps[PROP_NETWORK_MODE])) opts.networkMode = containerProps[PROP_NETWORK_MODE]; + + const tty = parseBool(containerProps[PROP_TTY]); + if (typeof tty === 'boolean') opts.tty = tty; + + const privilegedFromProps = parseBool(containerProps[PROP_PRIVILEGED]); + if (typeof privilegedFromProps === 'boolean') opts.privileged = privilegedFromProps; + else if (Array.isArray(main.requiredCapabilities) && main.requiredCapabilities.includes('privileged')) { + opts.privileged = true; + } + + if (isNonEmptyString(containerProps[PROP_LABELS_JSON])) { + try { + const parsed = JSON.parse(containerProps[PROP_LABELS_JSON]) as Record; + if (parsed && typeof parsed === 'object') opts.labels = parsed; + } catch { + // ignore malformed labels payloads + } + } + + if (isNonEmptyString(containerProps[PROP_CREATE_EXTRAS_JSON])) { + try { + const parsed = JSON.parse(containerProps[PROP_CREATE_EXTRAS_JSON]) as ContainerOpts['createExtras']; + if (parsed && typeof parsed === 'object') opts.createExtras = parsed; + } catch { + // ignore malformed extras payloads + } + } + + const volumeMap = new Map(); + for (const spec of request.volumes ?? []) { + if (!spec?.name) continue; + volumeMap.set(spec.name, spec); + } + + const binds: string[] = []; + const anonymous: string[] = []; + + for (const mount of main.mounts ?? []) { + if (!mount?.volume) continue; + const spec = volumeMap.get(mount.volume); + if (!spec) continue; + + if (spec.kind === VolumeKind.EPHEMERAL) { + if (isNonEmptyString(mount.mountPath)) anonymous.push(mount.mountPath); + continue; + } + + const source = isNonEmptyString(spec.persistentName) ? spec.persistentName : spec.name; + if (!isNonEmptyString(source) || !isNonEmptyString(mount.mountPath)) continue; + + const rawOptions = spec.additionalProperties?.[PROP_BIND_OPTIONS]; + let optionList = rawOptions + ? rawOptions + .split(',') + .map((opt: string) => opt.trim()) + .filter((opt: string) => opt.length > 0) + : []; + if (mount.readOnly) { + if (!optionList.includes('ro')) optionList.push('ro'); + } else { + optionList = optionList.filter((opt: string) => opt !== 'ro'); + } + const bindString = composeBindString(source, mount.mountPath, optionList); + binds.push(bindString); + } + + if (binds.length > 0) opts.binds = binds; + if (anonymous.length > 0) opts.anonymousVolumes = anonymous; + + const requestProps = cloneAdditionalProperties(request.additionalProperties); + const ttl = parseIntSafe(requestProps[PROP_TTL_SECONDS]); + if (typeof ttl === 'number') opts.ttlSeconds = ttl; + + if (isNonEmptyString(requestProps[PROP_PLATFORM])) { + opts.platform = requestProps[PROP_PLATFORM] as Platform; + } + + if (Array.isArray(request.sidecars) && request.sidecars.length > 0) { + const sidecars: SidecarOpts[] = []; + for (const spec of request.sidecars) { + if (!spec) continue; + const sidecarOpts: SidecarOpts = { + image: isNonEmptyString(spec.image) ? spec.image : '', + }; + if (Array.isArray(spec.cmd) && spec.cmd.length > 0) sidecarOpts.cmd = [...spec.cmd]; + const sidecarEnv = composeEnvRecord(spec.env ?? []); + if (sidecarEnv) sidecarOpts.env = sidecarEnv; + + const props = cloneAdditionalProperties(spec.additionalProperties); + const autoRemoveSidecar = parseBool(props[PROP_AUTO_REMOVE]); + if (typeof autoRemoveSidecar === 'boolean') sidecarOpts.autoRemove = autoRemoveSidecar; + + if (isNonEmptyString(props[PROP_NETWORK_MODE])) sidecarOpts.networkMode = props[PROP_NETWORK_MODE]; + + const privilegedSidecar = parseBool(props[PROP_PRIVILEGED]); + if (typeof privilegedSidecar === 'boolean') sidecarOpts.privileged = privilegedSidecar; + else if (Array.isArray(spec.requiredCapabilities) && spec.requiredCapabilities.includes('privileged')) { + sidecarOpts.privileged = true; + } + + if (isNonEmptyString(props[PROP_LABELS_JSON])) { + try { + const parsed = JSON.parse(props[PROP_LABELS_JSON]) as Record; + if (parsed && typeof parsed === 'object') sidecarOpts.labels = parsed; + } catch { + // ignore malformed labels payloads + } + } + + if (isNonEmptyString(props[PROP_CREATE_EXTRAS_JSON])) { + try { + const parsed = JSON.parse(props[PROP_CREATE_EXTRAS_JSON]) as SidecarOpts['createExtras']; + if (parsed && typeof parsed === 'object') sidecarOpts.createExtras = parsed; + } catch { + // ignore malformed extras payloads + } + } + + const anonymous: string[] = []; + for (const mount of spec.mounts ?? []) { + if (!mount?.volume) continue; + const volSpec = volumeMap.get(mount.volume); + if (!volSpec) continue; + if (volSpec.kind === VolumeKind.EPHEMERAL && isNonEmptyString(mount.mountPath)) { + anonymous.push(mount.mountPath); + } + } + if (anonymous.length > 0) sidecarOpts.anonymousVolumes = anonymous; + + sidecars.push(sidecarOpts); + } + if (sidecars.length > 0) opts.sidecars = sidecars; + } + + return opts; +}; diff --git a/packages/docker-runner/src/index.ts b/packages/docker-runner/src/index.ts new file mode 100644 index 000000000..5b285bf0a --- /dev/null +++ b/packages/docker-runner/src/index.ts @@ -0,0 +1,12 @@ +export * from './lib/container.service'; +export * from './lib/container.handle'; +export * from './lib/container.mounts'; +export * from './lib/containerStream.util'; +export * from './lib/containerRegistry.port'; +export * from './lib/dockerClient.port'; +export * from './lib/execTimeout'; +export * from './lib/types'; +export * from './contracts/auth'; +export * from './contracts/json'; +export * from './contracts/api'; +export * from './contracts/workload.grpc'; diff --git a/packages/docker-runner/src/lib/container.handle.ts b/packages/docker-runner/src/lib/container.handle.ts new file mode 100644 index 000000000..b0964b2fa --- /dev/null +++ b/packages/docker-runner/src/lib/container.handle.ts @@ -0,0 +1,30 @@ +import type { DockerClientPort } from './dockerClient.port'; +import type { ExecOptions } from './types'; + +/** + * Lightweight entity wrapper representing a running (or created) container. + * Provides convenience methods delegating to ContainerService while binding the docker id. + */ +export class ContainerHandle { + constructor( + private readonly service: DockerClientPort, + public readonly id: string, + ) {} + + exec(command: string[] | string, options?: ExecOptions) { + return this.service.execContainer(this.id, command, options); + } + + stop(timeoutSec = 10) { + return this.service.stopContainer(this.id, timeoutSec); + } + remove(options?: boolean | { force?: boolean; removeVolumes?: boolean }) { + return this.service.removeContainer(this.id, options); + } + + /** Upload a tar archive into the container filesystem (defaults to /tmp). */ + putArchive(data: Buffer | NodeJS.ReadableStream, options: { path?: string } = { path: '/tmp' }) { + const path = options?.path || '/tmp'; + return this.service.putArchive(this.id, data, { path }); + } +} diff --git a/packages/docker-runner/src/lib/container.mounts.ts b/packages/docker-runner/src/lib/container.mounts.ts new file mode 100644 index 000000000..8bdb82721 --- /dev/null +++ b/packages/docker-runner/src/lib/container.mounts.ts @@ -0,0 +1,35 @@ +import type { ContainerInspectInfo } from './types'; + +export interface ContainerMount { + source: string; + destination: string; +} + +export function mapInspectMounts(mounts: ContainerInspectInfo['Mounts'] | undefined | null): ContainerMount[] { + if (!Array.isArray(mounts)) return []; + const result: ContainerMount[] = []; + for (const mount of mounts) { + if (!mount) continue; + const name = typeof mount.Name === 'string' ? mount.Name.trim() : ''; + const sourceRaw = typeof mount.Source === 'string' ? mount.Source.trim() : ''; + const destination = typeof mount.Destination === 'string' ? mount.Destination.trim() : ''; + if (!destination) continue; + const source = name || sourceRaw; + if (!source) continue; + result.push({ source, destination }); + } + return result; +} + +export function sanitizeContainerMounts(input: unknown): ContainerMount[] { + if (!Array.isArray(input)) return []; + const result: ContainerMount[] = []; + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + const source = typeof (entry as { source?: unknown }).source === 'string' ? (entry as { source: string }).source.trim() : ''; + const destination = typeof (entry as { destination?: unknown }).destination === 'string' ? (entry as { destination: string }).destination.trim() : ''; + if (!source || !destination) continue; + result.push({ source, destination }); + } + return result; +} diff --git a/packages/docker-runner/src/lib/containerRegistry.port.ts b/packages/docker-runner/src/lib/containerRegistry.port.ts new file mode 100644 index 000000000..a0490a7f9 --- /dev/null +++ b/packages/docker-runner/src/lib/containerRegistry.port.ts @@ -0,0 +1,19 @@ +import type { ContainerMount } from './container.mounts'; + +export type RegisterContainerStartInput = { + containerId: string; + nodeId: string; + threadId: string; + image: string; + providerType?: 'docker'; + labels?: Record; + platform?: string; + ttlSeconds?: number; + mounts?: ContainerMount[]; + name: string; +}; + +export interface ContainerRegistryPort { + updateLastUsed(containerId: string, now: Date, ttlOverrideSeconds?: number): Promise; + registerStart(input: RegisterContainerStartInput): Promise; +} diff --git a/packages/docker-runner/src/lib/containerStream.util.ts b/packages/docker-runner/src/lib/containerStream.util.ts new file mode 100644 index 000000000..65635dc61 --- /dev/null +++ b/packages/docker-runner/src/lib/containerStream.util.ts @@ -0,0 +1,104 @@ +import { PassThrough } from 'node:stream'; + +/** + * Streaming UTF-8 decoder/collector that properly handles multibyte boundaries. + * Optionally caps retained output to avoid unbounded memory usage. + */ +export function createUtf8Collector(limitChars?: number) { + const decoder = new TextDecoder('utf-8'); + let text = ''; + let truncated = false; + const cap = limitChars ?? Number.POSITIVE_INFINITY; + + const appendDecoded = (decoded: string) => { + if (!decoded || truncated) return; + if (text.length + decoded.length <= cap) { + text += decoded; + return; + } + const remaining = Math.max(0, cap - text.length); + if (remaining > 0) { + text += decoded.slice(0, remaining); + } + truncated = true; + }; + + return { + append(chunk: Buffer | string) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const decoded = decoder.decode(buf, { stream: true }); + appendDecoded(decoded); + }, + flush() { + const decoded = decoder.decode(); + appendDecoded(decoded); + }, + getText() { + return text; + }, + isTruncated() { + return truncated; + }, + }; +} + +/** + * Manual demux of a Docker multiplexed stream (when TTY=false). + * Uses 8-byte headers to distribute payload to stdout/stderr. + * Falls back to writing raw data to stdout when header looks invalid, and switches to passthrough for the remainder. + */ +export function demuxDockerMultiplex(stream: NodeJS.ReadableStream) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let buffer: Buffer | null = null; + let passthrough = false; + const MAX_FRAME_LEN = 64 * 1024 * 1024; // 64 MiB sanity cap + + const onData = (chunk: Buffer) => { + if (passthrough) { + stdout.write(chunk); + return; + } + buffer = buffer ? Buffer.concat([buffer, chunk]) : chunk; + while (buffer && buffer.length >= 8) { + const type = buffer[0]; + const r1 = buffer[1], r2 = buffer[2], r3 = buffer[3]; + const len = buffer.readUInt32BE(4); + const saneHeader = (r1 | r2 | r3) === 0 && len >= 0 && len <= MAX_FRAME_LEN; + if (!saneHeader) { + // Not a multiplexed stream; treat the remainder as stdout and stop demuxing + if (buffer && buffer.length) stdout.write(buffer); + buffer = null; + passthrough = true; + return; + } + if (buffer.length < 8 + len) return; // incomplete frame + const payload = buffer.subarray(8, 8 + len); + if (type === 1) stdout.write(payload); + else if (type === 2) stderr.write(payload); + else if (type === 0) { + // stdin frame; ignore per Docker multiplexing spec + } else { + // Unknown type: do not break framing; forward to stdout explicitly + stdout.write(payload); + } + buffer = buffer.subarray(8 + len); + } + }; + + stream.on('data', (c: Buffer | string) => onData(Buffer.isBuffer(c) ? c : Buffer.from(c))); + const endBoth = () => { + // If we have buffered bytes (partial frame/header), emit them as raw stdout + if (buffer && buffer.length) stdout.write(buffer); + stdout.end(); + stderr.end(); + }; + stream.on('end', endBoth); + stream.on('close', endBoth); + stream.on('error', (e) => { + stdout.emit('error', e); + stderr.emit('error', e); + }); + + return { stdout, stderr }; +} diff --git a/packages/docker-runner/src/lib/dockerClient.port.ts b/packages/docker-runner/src/lib/dockerClient.port.ts new file mode 100644 index 000000000..bcced5b17 --- /dev/null +++ b/packages/docker-runner/src/lib/dockerClient.port.ts @@ -0,0 +1,42 @@ +import type { ContainerHandle } from './container.handle'; +import type { + ContainerInspectInfo, + ContainerOpts, + ExecOptions, + ExecResult, + InteractiveExecOptions, + InteractiveExecSession, + LogsStreamOptions, + LogsStreamSession, + Platform, +} from './types'; + +export type DockerEventFilters = Record>; + +export interface DockerClientPort { + touchLastUsed(containerId: string): Promise; + ensureImage(image: string, platform?: Platform): Promise; + start(opts?: ContainerOpts): Promise; + execContainer(containerId: string, command: string[] | string, options?: ExecOptions): Promise; + openInteractiveExec( + containerId: string, + command: string[] | string, + options?: InteractiveExecOptions, + ): Promise; + streamContainerLogs(containerId: string, options?: LogsStreamOptions): Promise; + resizeExec(execId: string, size: { cols: number; rows: number }): Promise; + stopContainer(containerId: string, timeoutSec?: number): Promise; + removeContainer( + containerId: string, + options?: boolean | { force?: boolean; removeVolumes?: boolean }, + ): Promise; + getContainerLabels(containerId: string): Promise | undefined>; + getContainerNetworks(containerId: string): Promise; + findContainersByLabels(labels: Record, options?: { all?: boolean }): Promise; + listContainersByVolume(volumeName: string): Promise; + removeVolume(volumeName: string, options?: { force?: boolean }): Promise; + findContainerByLabels(labels: Record, options?: { all?: boolean }): Promise; + putArchive(containerId: string, data: Buffer | NodeJS.ReadableStream, options: { path: string }): Promise; + inspectContainer(containerId: string): Promise; + getEventsStream(options: { since?: number; filters?: DockerEventFilters }): Promise; +} diff --git a/packages/docker-runner/src/lib/execTimeout.ts b/packages/docker-runner/src/lib/execTimeout.ts new file mode 100644 index 000000000..132c4906f --- /dev/null +++ b/packages/docker-runner/src/lib/execTimeout.ts @@ -0,0 +1,51 @@ +// Helper to detect exec timeout errors consistently across modules +export const EXEC_TIMEOUT_RE = /^Exec timed out after \d+ms/; +export const EXEC_IDLE_TIMEOUT_RE = /^Exec idle timed out after \d+ms/; + +/** + * Error thrown when an exec operation exceeds the provided timeout. + * Carries timeoutMs and any captured stdout/stderr up to the point of timeout. + */ +export class ExecTimeoutError extends Error { + timeoutMs: number; + stdout: string; + stderr: string; + constructor(timeoutMs: number, stdout: string, stderr: string) { + super(`Exec timed out after ${timeoutMs}ms`); + this.name = 'ExecTimeoutError'; + this.timeoutMs = timeoutMs; + this.stdout = stdout; + this.stderr = stderr; + } +} + +export function isExecTimeoutError(err: unknown): err is Error { + return ( + err instanceof ExecTimeoutError + || (err instanceof Error && EXEC_TIMEOUT_RE.test(err.message)) + ); +} + +/** + * Error thrown when an exec operation produces no output for idleTimeoutMs. + * Carries timeoutMs and any captured stdout/stderr up to the point of timeout. + */ +export class ExecIdleTimeoutError extends Error { + timeoutMs: number; + stdout: string; + stderr: string; + constructor(timeoutMs: number, stdout: string, stderr: string) { + super(`Exec idle timed out after ${timeoutMs}ms`); + this.name = 'ExecIdleTimeoutError'; + this.timeoutMs = timeoutMs; + this.stdout = stdout; + this.stderr = stderr; + } +} + +export function isExecIdleTimeoutError(err: unknown): err is Error { + return ( + err instanceof ExecIdleTimeoutError + || (err instanceof Error && EXEC_IDLE_TIMEOUT_RE.test(err.message)) + ); +} diff --git a/packages/docker-runner/src/lib/types.ts b/packages/docker-runner/src/lib/types.ts new file mode 100644 index 000000000..762782480 --- /dev/null +++ b/packages/docker-runner/src/lib/types.ts @@ -0,0 +1,123 @@ +import type { ContainerCreateOptions } from 'dockerode'; + +export const SUPPORTED_PLATFORMS = ['linux/amd64', 'linux/arm64'] as const; +export type Platform = (typeof SUPPORTED_PLATFORMS)[number]; + +export const PLATFORM_LABEL = 'hautech.ai/platform'; + +export type SidecarOpts = { + image: string; + cmd?: string[]; + env?: Record | string[]; + privileged?: boolean; + autoRemove?: boolean; + anonymousVolumes?: string[]; + labels?: Record; + createExtras?: Partial; + networkMode?: string; +}; + +export type ContainerOpts = { + image?: string; + name?: string; + cmd?: string[]; + entrypoint?: string; + env?: Record | string[]; + workingDir?: string; + autoRemove?: boolean; + binds?: string[]; + networkMode?: string; + tty?: boolean; + labels?: Record; + platform?: Platform; + privileged?: boolean; + anonymousVolumes?: string[]; + createExtras?: Partial; + ttlSeconds?: number; + sidecars?: SidecarOpts[]; +}; + +export type ExecOptions = { + workdir?: string; + env?: Record | string[]; + timeoutMs?: number; + idleTimeoutMs?: number; + tty?: boolean; + killOnTimeout?: boolean; + signal?: AbortSignal; + onOutput?: (source: 'stdout' | 'stderr', chunk: Buffer) => void; + logToPid1?: boolean; +}; + +export type ExecResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +export type InteractiveExecOptions = { + workdir?: string; + env?: Record | string[]; + tty?: boolean; + demuxStderr?: boolean; +}; + +export type ExecInspectInfo = { + Running?: boolean; + ExitCode?: number | null; +}; + +export type InteractiveExecSession = { + stdin: NodeJS.WritableStream; + stdout: NodeJS.ReadableStream; + stderr?: NodeJS.ReadableStream; + close: () => Promise; + inspect: () => Promise; + execId: string; + terminateProcessGroup: (reason: 'timeout' | 'idle_timeout') => Promise; +}; + +export type LogsStreamOptions = { + follow?: boolean; + since?: number; + tail?: number; + stdout?: boolean; + stderr?: boolean; + timestamps?: boolean; +}; + +export type LogsStreamSession = { + stream: NodeJS.ReadableStream; + close: () => Promise; +}; + +export type ContainerInspectMount = { + Type?: string; + Source?: string; + Destination?: string; + RW?: boolean; + ReadOnly?: boolean; + Name?: string; +}; + +export type ContainerInspectState = { + Status?: string; + Running?: boolean; +}; + +export type ContainerInspectNetworkSettings = { + Networks?: Record>; +}; + +export type ContainerInspectInfo = { + Id?: string; + Name?: string; + Image?: string; + Config?: { + Image?: string; + Labels?: Record; + }; + Mounts?: ContainerInspectMount[]; + State?: ContainerInspectState; + NetworkSettings?: ContainerInspectNetworkSettings; +}; diff --git a/packages/docker-runner/src/service/config.ts b/packages/docker-runner/src/service/config.ts new file mode 100644 index 000000000..8091fe139 --- /dev/null +++ b/packages/docker-runner/src/service/config.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +const runnerConfigSchema = z.object({ + grpcPort: z + .union([z.string(), z.number()]) + .default('50051') + .transform((value) => { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : 50051; + }), + grpcHost: z.string().default('0.0.0.0'), + sharedSecret: z.string().min(1, 'DOCKER_RUNNER_SHARED_SECRET is required'), + signatureTtlMs: z + .union([z.string(), z.number()]) + .default('60000') + .transform((value) => { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : 60_000; + }), + dockerSocket: z.string().default('/var/run/docker.sock'), + logLevel: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), +}); + +export type RunnerConfig = z.infer; + +export function loadRunnerConfig(env: NodeJS.ProcessEnv = process.env): RunnerConfig { + const grpcPortEnv = env.DOCKER_RUNNER_PORT ?? env.DOCKER_RUNNER_GRPC_PORT; + const parsed = runnerConfigSchema.safeParse({ + grpcPort: grpcPortEnv, + grpcHost: env.DOCKER_RUNNER_GRPC_HOST, + sharedSecret: env.DOCKER_RUNNER_SHARED_SECRET, + signatureTtlMs: env.DOCKER_RUNNER_SIGNATURE_TTL_MS, + dockerSocket: env.DOCKER_SOCKET ?? env.DOCKER_RUNNER_SOCKET, + logLevel: env.DOCKER_RUNNER_LOG_LEVEL, + }); + if (!parsed.success) { + throw new Error(`Invalid docker-runner configuration: ${parsed.error.message}`); + } + return parsed.data; +} diff --git a/packages/docker-runner/src/service/dockerEvents.parser.ts b/packages/docker-runner/src/service/dockerEvents.parser.ts new file mode 100644 index 000000000..b12b67438 --- /dev/null +++ b/packages/docker-runner/src/service/dockerEvents.parser.ts @@ -0,0 +1,70 @@ +type Chunk = Buffer | string | ArrayBuffer | Uint8Array | null | undefined; + +type DockerEvent = Record; + +export type DockerEventsParserOptions = { + onError?: (payload: string, error: unknown) => void; +}; + +export type DockerEventsParser = { + handleChunk: (chunk: Chunk) => void; + flush: () => void; +}; + +const chunkToString = (chunk: Chunk): string => { + if (!chunk) return ''; + if (typeof chunk === 'string') return chunk; + if (Buffer.isBuffer(chunk)) return chunk.toString('utf8'); + if (chunk instanceof ArrayBuffer) return Buffer.from(chunk).toString('utf8'); + if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString('utf8'); + return String(chunk); +}; + +const drainBuffer = ( + buffer: string, + emit: (event: DockerEvent) => void, + options?: DockerEventsParserOptions, + force = false, +): { buffer: string } => { + let rest = buffer; + while (true) { + const newlineIdx = rest.indexOf('\n'); + if (newlineIdx === -1) break; + const raw = rest.slice(0, newlineIdx).trim(); + rest = rest.slice(newlineIdx + 1); + if (!raw) continue; + try { + emit(JSON.parse(raw)); + } catch (error) { + options?.onError?.(raw, error); + } + } + if (force) { + const trailing = rest.trim(); + rest = ''; + if (trailing) { + try { + emit(JSON.parse(trailing)); + } catch (error) { + options?.onError?.(trailing, error); + } + } + } + return { buffer: rest }; +}; + +export const createDockerEventsParser = ( + emit: (event: DockerEvent) => void, + options?: DockerEventsParserOptions, +): DockerEventsParser => { + let buffer = ''; + const handleChunk = (chunk: Chunk) => { + if (!chunk) return; + buffer += chunkToString(chunk); + ({ buffer } = drainBuffer(buffer, emit, options)); + }; + const flush = () => { + ({ buffer } = drainBuffer(buffer, emit, options, true)); + }; + return { handleChunk, flush }; +}; diff --git a/packages/docker-runner/src/service/env.ts b/packages/docker-runner/src/service/env.ts new file mode 100644 index 000000000..c624585af --- /dev/null +++ b/packages/docker-runner/src/service/env.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { config } from 'dotenv'; + +const isProduction = () => process.env.NODE_ENV?.toLowerCase() === 'production'; + +function resolveCandidatePaths(): string[] { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const repoEnv = path.resolve(process.cwd(), '.env'); + const packageEnv = path.resolve(moduleDir, '../../.env'); + return [repoEnv, packageEnv]; +} + +function loadDotenv(): void { + const tried = new Set(); + + for (const candidate of resolveCandidatePaths()) { + const normalized = path.normalize(candidate); + if (tried.has(normalized)) continue; + tried.add(normalized); + + if (!fs.existsSync(normalized)) continue; + + config({ + path: normalized, + override: false, + }); + return; + } +} + +if (!isProduction()) { + loadDotenv(); +} diff --git a/packages/docker-runner/src/service/grpc/server.ts b/packages/docker-runner/src/service/grpc/server.ts index c7916e52a..191fb6665 100644 --- a/packages/docker-runner/src/service/grpc/server.ts +++ b/packages/docker-runner/src/service/grpc/server.ts @@ -42,9 +42,9 @@ import { } from '../../proto/gen/agynio/api/runner/v1/runner_pb.js'; import { timestampFromDate } from '@bufbuild/protobuf/wkt'; import { create } from '@bufbuild/protobuf'; -import type { ContainerService, InteractiveExecSession, LogsStreamSession, NonceCache } from '../..'; +import type { ContainerService, InteractiveExecSession, LogsStreamSession, NonceCache } from '../../index.js'; import type { ContainerHandle } from '../../lib/container.handle'; -import { verifyAuthHeaders } from '../..'; +import { verifyAuthHeaders } from '../../index.js'; import type { RunnerConfig } from '../config'; import { createDockerEventsParser } from '../dockerEvents.parser'; import { startWorkloadRequestToContainerOpts } from '../../contracts/workload.grpc'; diff --git a/packages/docker-runner/src/service/main.ts b/packages/docker-runner/src/service/main.ts index 23ea08e3c..9d2f8a2f2 100644 --- a/packages/docker-runner/src/service/main.ts +++ b/packages/docker-runner/src/service/main.ts @@ -1,6 +1,6 @@ import './env'; -import { ContainerService, NonceCache } from '..'; +import { ContainerService, NonceCache } from '../index.js'; import { loadRunnerConfig } from './config'; import { createRunnerGrpcServer } from './grpc/server'; diff --git a/packages/docker-runner/tsconfig.eslint.json b/packages/docker-runner/tsconfig.eslint.json new file mode 100644 index 000000000..ad071c53e --- /dev/null +++ b/packages/docker-runner/tsconfig.eslint.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "__tests__"] +} diff --git a/packages/docker-runner/tsconfig.json b/packages/docker-runner/tsconfig.json new file mode 100644 index 000000000..523994211 --- /dev/null +++ b/packages/docker-runner/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", + "composite": true, + "types": ["node"] + }, + "include": ["src"] +} From 142612e40cf09e9444eb5fd07bbbab6699b28994 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sun, 15 Mar 2026 20:32:55 +0000 Subject: [PATCH 35/43] fix(graph): align teams snapshot updates --- README.md | 2 +- buf.gen.yaml | 8 - docs/api/index.md | 19 +- docs/graph/status-updates.md | 37 +- docs/product-spec.md | 10 +- docs/security/vault.md | 4 +- docs/ui/config-views.md | 8 +- docs/ui/graph/README.md | 12 +- docs/ui/graph/index.md | 4 +- .../__e2e__/app.bootstrap.smoke.test.ts | 2 - .../agents.persistence.metrics_titles.test.ts | 8 +- .../__tests__/graph.mcp.integration.test.ts | 5 +- .../__tests__/graph.module.di.smoke.test.ts | 2 - .../graph.socket.gateway.bus.test.ts | 15 - .../__tests__/helpers/eventsBus.stub.ts | 4 - .../__tests__/helpers/runEvents.stub.ts | 2 - .../__tests__/helpers/teamsGrpc.stub.ts | 62 +- ...ime.simpleAgent.config.propagation.test.ts | 1 - .../localMcpServer.heartbeat.test.ts | 3 +- .../__tests__/localMcpServer.test.ts | 17 +- .../__tests__/manage.tool.test.ts | 1 - .../__tests__/mcp-lifecycle-changes.test.ts | 7 +- .../mcp.enabledTools.boot.integration.test.ts | 181 ------ .../mcp.listTools.snapshot_fallback.test.ts | 69 +- .../mcp.preload.stale.persist.test.ts | 35 +- .../__tests__/mcp.provision.dynamic.test.ts | 42 +- .../__tests__/mcp.tools.sync.test.ts | 70 +-- .../memory.runtime.integration.test.ts | 1 - .../mixed.shell.mcp.isolation.test.ts | 3 +- .../__tests__/nodeState.service.merge.test.ts | 18 - .../__tests__/routes.node.actions.test.ts | 107 +++- .../__tests__/routes.variables.test.ts | 104 --- .../__tests__/runtime.api.helpers.test.ts | 1 - .../runtime.config.unknownKeys.test.ts | 20 - .../__tests__/socket.events.test.ts | 36 -- .../__tests__/socket.gateway.test.ts | 1 - .../__tests__/socket.metrics.coalesce.test.ts | 1 - .../socket.node_status.integration.test.ts | 1 - .../socket.realtime.integration.test.ts | 1 - .../__tests__/teams/teamsGrpc.client.test.ts | 6 +- .../__tests__/teamsGraph.source.test.ts | 26 +- packages/platform-server/prisma/schema.prisma | 27 - .../src/agents/agents.persistence.service.ts | 8 +- .../platform-server/src/env/env.module.ts | 3 +- .../src/events/events-bus.service.ts | 18 - .../src/gateway/graph.socket.gateway.ts | 35 +- .../src/graph-core/liveGraph.manager.ts | 26 +- .../src/graph/controllers/graph.controller.ts | 63 +- .../controllers/graphPersist.controller.ts | 17 - .../controllers/graphVariables.controller.ts | 78 --- .../graph/controllers/variables.controller.ts | 127 ++++ .../src/graph/graph-api.module.ts | 15 +- .../src/graph/graph.repository.ts | 1 - .../src/graph/nodeState.service.ts | 75 --- .../graph/services/graphVariables.service.ts | 76 --- .../src/graph/teamsGraph.repository.ts | 43 +- .../src/graph/teamsGraph.source.ts | 96 ++- .../src/nodes/mcp/localMcpServer.node.ts | 181 ++---- .../src/nodes/mcp/localMcpServer.tool.ts | 3 + packages/platform-server/src/proto/grpc.ts | 145 +++-- .../src/shared/types/graph.types.ts | 7 - .../src/teams/teamsGrpc.client.ts | 176 +++++- .../src/teams/teamsGrpc.pagination.ts | 22 +- .../src/utils/reference-resolver.service.ts | 24 +- .../McpServerDynamicConfigView.test.tsx | 74 --- .../__tests__/integration/testUtils.tsx | 2 - packages/platform-ui/src/api/hooks/graph.ts | 3 +- packages/platform-ui/src/api/modules/graph.ts | 9 +- packages/platform-ui/src/api/types/graph.ts | 2 +- .../platform-ui/src/components/ToolItem.tsx | 33 +- .../src/components/agents/GraphLayout.tsx | 84 +-- .../components/entities/EntityUpsertForm.tsx | 35 +- .../src/components/graph/NodeStatusBadges.tsx | 5 +- .../graph/__tests__/NodeDetailsPanel.test.tsx | 8 +- .../components/nodeProperties/McpSection.tsx | 45 +- .../components/nodeProperties/ToolsList.tsx | 44 +- .../__tests__/NodePropertiesSidebar.test.tsx | 1 - ...dePropertiesSidebar.workspace.env.test.tsx | 1 - .../NodePropertiesSidebar.workspace.test.tsx | 1 - .../src/components/nodeProperties/index.tsx | 8 +- .../src/components/nodeProperties/types.ts | 4 +- .../components/nodeProperties/viewTypes.ts | 2 +- .../views/McpNodeConfigView.tsx | 64 +- .../GraphLayout.integration.test.tsx | 87 +-- ...hLayout.workspace.env.integration.test.tsx | 12 +- .../hooks/__tests__/useGraphData.test.ts | 3 +- .../hooks/__tests__/useGraphSocket.test.ts | 21 - .../hooks/__tests__/useNodeStatus.test.tsx | 4 - .../src/features/graph/hooks/useGraphData.ts | 21 - .../features/graph/hooks/useGraphSocket.ts | 19 +- .../src/features/graph/hooks/useNodeAction.ts | 2 - .../src/features/graph/hooks/useNodeState.ts | 80 --- .../src/features/graph/mappers/index.ts | 11 +- .../services/__tests__/api.actions.test.ts | 3 +- .../src/features/graph/services/api.ts | 53 +- .../src/features/graph/services/socket.ts | 17 - .../src/features/graph/types/index.ts | 3 - .../src/lib/graph/__tests__/api.test.ts | 4 +- .../graph/__tests__/hooks.optimistic.test.tsx | 2 +- .../src/lib/graph/__tests__/hooks.test.tsx | 12 +- packages/platform-ui/src/lib/graph/hooks.ts | 117 +--- packages/platform-ui/src/lib/graph/socket.ts | 25 - packages/platform-ui/src/lib/graph/types.ts | 1 - .../__tests__/entities-list-page.test.tsx | 2 +- proto/agynio/api/teams/v1/teams.proto | 590 ++++++++++++------ 105 files changed, 1569 insertions(+), 2167 deletions(-) delete mode 100644 packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts delete mode 100644 packages/platform-server/__tests__/nodeState.service.merge.test.ts delete mode 100644 packages/platform-server/__tests__/routes.variables.test.ts delete mode 100644 packages/platform-server/src/graph/controllers/graphPersist.controller.ts delete mode 100644 packages/platform-server/src/graph/controllers/graphVariables.controller.ts create mode 100644 packages/platform-server/src/graph/controllers/variables.controller.ts delete mode 100644 packages/platform-server/src/graph/nodeState.service.ts delete mode 100644 packages/platform-server/src/graph/services/graphVariables.service.ts delete mode 100644 packages/platform-ui/__tests__/components/configViews/McpServerDynamicConfigView.test.tsx delete mode 100644 packages/platform-ui/src/features/graph/hooks/useNodeState.ts diff --git a/README.md b/README.md index f2dbff29a..35e59a4d7 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ pnpm --filter @agyn/platform-server run prisma:generate ## API Docs - See docs/api/index.md for current HTTP and socket endpoints: - - /api/templates, /api/graph, /graph/templates, /graph/nodes/:nodeId/status, /api/agents/runs/:runId/events, /api/agents/context-items, dynamic-config schema, Vault proxy routes, Nix proxy routes, socket events. + - /api/templates, /api/graph, /graph/templates, /graph/nodes/:nodeId/status, /graph/nodes/:nodeId/discover-tools, /api/graph/variables, /api/agents/runs/:runId/events, /api/agents/context-items, Vault proxy routes, Nix proxy routes, socket events. - No OpenAPI/Swagger spec checked in; discover via docs/api/index.md and controllers under packages/platform-server/src/graph/ (GraphApiModule wiring). ## Deployment diff --git a/buf.gen.yaml b/buf.gen.yaml index 234b9da04..661c871eb 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -12,14 +12,6 @@ plugins: out: packages/docker-runner/src/proto/gen opt: - target=ts - - plugin: buf.build/bufbuild/connect-es - out: packages/platform-server/src/proto/gen - opt: - - target=ts - - plugin: buf.build/bufbuild/es - out: packages/docker-runner/src/proto/gen - opt: - - target=ts - plugin: buf.build/bufbuild/connect-es out: packages/docker-runner/src/proto/gen opt: diff --git a/docs/api/index.md b/docs/api/index.md index 57206a65b..2c7602841 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -49,12 +49,16 @@ Templates alias Node status and actions - GET `/graph/nodes/:nodeId/status` - - 200: `{ isPaused?, provisionStatus?, dynamicConfigReady? }` + - 200: `{ provisionStatus? }` - POST `/graph/nodes/:nodeId/actions` - - Body: `{ action: 'pause'|'resume'|'provision'|'deprovision' }` + - Body: `{ action: 'provision'|'deprovision' }` - 204: no body on success; server also emits a `node_status` socket event - 400 `{ error: 'unknown_action' }` - 500 `{ error: string }` + - POST `/graph/nodes/:nodeId/discover-tools` + - 200 `{ tools: Array<{ name: string; description?: string }>, updatedAt?: string }` + - 400 `{ error: 'node_not_mcp' }` + - 404 `{ error: 'node_not_found' }` Agent runs timeline - GET `/api/agents/runs/:runId/events` @@ -75,12 +79,6 @@ Context items - 200 `{ items: Array<{ id, role, contentText, contentJson, metadata, sizeBytes, createdAt }> }` - Empty `ids` returns `{ items: [] }`. -Dynamic-config schema (read-only) -- GET `/graph/nodes/:nodeId/dynamic-config/schema` - - 200: `{ ready: boolean, schema?: JSONSchema }` - - 404: `{ error: 'node_not_found' }` - - 500: `{ error: 'dynamic_config_schema_error' | string }` - Vault proxy (enabled only when VAULT_ENABLED=true) - GET `/api/vault/mounts` → `{ items: string[] }` - GET `/api/vault/kv/:mount/paths?prefix=` → `{ items: string[] }` @@ -93,13 +91,12 @@ Vault proxy (enabled only when VAULT_ENABLED=true) Sockets - Default namespace (no custom path) - - Event `node_status`: `{ nodeId, isPaused?, provisionStatus?, dynamicConfigReady?, updatedAt }` - - Event `node_config`: `{ nodeId, config, dynamicConfig, version }` (emitted after successful /api/graph save with changes) + - Event `node_status`: `{ nodeId, provisionStatus?, updatedAt? }` - See docs/graph/status-updates.md and docs/ui/graph/index.md Notes - Route handlers surface structured errors and emit socket events on state changes. -- The filesystem store enforces deterministic edge IDs and uses a dataset-scoped file lock plus atomic writes. +- Graph snapshots are sourced from the Teams service; `/api/graph` is read-only. - MCP mutation guard prevents unsafe changes to MCP commands. - Error codes align with the error envelope described above. Nix proxy diff --git a/docs/graph/status-updates.md b/docs/graph/status-updates.md index 410051575..450affd35 100644 --- a/docs/graph/status-updates.md +++ b/docs/graph/status-updates.md @@ -7,15 +7,13 @@ Transport: socket.io - Payload: { nodeId: string, - isPaused?: boolean, provisionStatus?: { state: string; [k: string]: unknown }, - dynamicConfigReady?: boolean, - updatedAt: string + updatedAt?: string } Client guidance - Connect to the default namespace and subscribe to `node_status`. -- Server emits `node_status` for relevant changes: pause/resume, provision status updates, dynamic-config readiness. +- Server emits `node_status` for provision status changes. - Initial render can still use HTTP GET /graph/nodes/:nodeId/status; subsequent updates should come via socket.io push. Example (client) @@ -27,40 +25,29 @@ socket.on('connect', () => { }); socket.on('node_status', (payload) => { - // { nodeId, isPaused?, provisionStatus?, dynamicConfigReady?, updatedAt } + // { nodeId, provisionStatus?, updatedAt } updateUI(payload); }); Notes -- HTTP endpoints remain for actions (pause/resume, provision/deprovision) and node state updates. +- HTTP endpoints remain for actions (provision/deprovision). - Remove any polling loops (e.g., 2s intervals) for status; rely on socket events. Graph source and persistence - Graph configuration is sourced from the Teams service; `/api/graph` is GET-only and returns the latest snapshot. - UI edits to layout are local-only; the backend does not accept full-graph writes. -- Node state persists via `/api/graph/nodes/:nodeId/state` and is merged into the snapshot on read. -- Graph variables persist via `/api/graph/variables`; local overrides are stored separately. -- The per-node dynamic-config save endpoint was removed; only the schema endpoint remains for rendering purposes. +- Node state is not persisted; node status reflects runtime provisioning only. +- Graph variables are managed via the Teams service and exposed via `/api/graph/variables`. +- MCP tool lists refresh via `POST /api/graph/nodes/:nodeId/discover-tools`. -## Template Capabilities & Static Config (Updated) +## Template Schema (Updated) -Each template now advertises its capabilities and optional static configuration schema via the `/api/templates` and `/graph/templates` endpoints. UI palette entries can introspect: +The `/api/templates` and `/graph/templates` endpoints return the palette schema: -- `capabilities.pausable`: Node supports pause/resume (triggers, agents). -- `capabilities.provisionable`: Node exposes provision/deprovision lifecycle (Slack trigger, MCP server). -- `capabilities.staticConfigurable`: Node accepts an initial static config that is applied through `setConfig` (agent, container provider, call_agent tool, MCP server). -- `capabilities.dynamicConfigurable`: Node exposes a dynamic runtime config surface (MCP server tool enable/disable) once `dynamicConfigReady` is true. +- `name`, `title`, `kind` +- `sourcePorts`, `targetPorts` -Static config schemas (all templates now expose one – some are currently empty placeholders to allow forward-compatible UI forms): -- `simpleAgent`: title, systemPrompt, summarization options. -- `containerProvider`: image, env map. -- `callAgentTool`: description, name override. -- `mcpServer`: namespace, command, workdir, timeouts, restart strategy. -- `shellTool`: (empty object for now). -- `githubCloneRepoTool`: (empty object for now). -- `sendSlackMessageTool`: (empty object for now). - -Dynamic config (currently only MCP server) becomes available after initial tool discovery; UI should check `dynamicConfigReady` before rendering its form. +Capability flags and config schemas are not included in the palette response. Wiring timing and run state visibility - During server bootstrap, globalThis.liveGraphRuntime and globalThis.__agentRunsService must be assigned before applying any persisted graph to the runtime. diff --git a/docs/product-spec.md b/docs/product-spec.md index 9542e4ac9..2cdf6cff5 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -33,9 +33,9 @@ Architecture and components - Checkpointing via Postgres (default); streaming UI integration planned. - Server - HTTP APIs and Socket.IO for management and status streaming. - - Endpoints manage graph templates, graph state, node lifecycle/actions, dynamic-config schema, reminders, runs, vault proxy, and Nix proxy (when enabled). + - Endpoints manage graph templates, graph snapshots, node lifecycle/actions, MCP tool discovery, reminders, runs, vault proxy, variable CRUD, and Nix proxy (when enabled). - Persistence - - Graph store: filesystem dataset (format: 2) with deterministic edge IDs, dataset-level file locks, and staged working-tree swaps. Each upsert builds a full graph tree in a sibling directory, fsyncs it, and atomically swaps it into place (conflict/timeout/persist error modes preserved). + - Graph store: Teams service snapshot via gRPC; platform-server keeps no local graph persistence and treats `/api/graph` as read-only. - Container registry: Postgres table of workspace lifecycle and TTL; cleanup service with backoff. - Containers and workspace runtime - Workspaces via container provider; labeled hautech.ai/role=workspace and hautech.ai/thread_id; optional hautech.ai/platform for platform-aware reuse. Networking is managed by the runner. Optional DinD sidecar with DOCKER_HOST=tcp://localhost:2375. Optional HTTP-only registry mirror reachable at http://registry-mirror:5000. @@ -54,11 +54,11 @@ Features and capabilities Core data model and state - Graph - - Nodes: typed by template; static config schema; capabilities (pausable, provisionable, static/dynamic configurable). + - Nodes: typed by template; configs applied via setConfig; template metadata provides kind and ports. - Edges: deterministic IDs; reversible by PortsRegistry knowledge. - Version: monotonically increasing; optimistic locking on apply; single graph “main”. - Runtime status - - Per-node paused flag; provision status (not_ready, provisioning, ready, deprovisioning, error); per-node dynamic-config readiness. + - Per-node provision status (not_ready, provisioning, ready, deprovisioning, provisioning_error, deprovisioning_error). - Containers - container_id, node_id, thread_id, image, status, last_used_at, kill_after_at, termination_reason, metadata.labels, metadata.platform, metadata.ttlSeconds. - Observability @@ -95,7 +95,7 @@ Performance and scale - Observability storage relies on Postgres; add indices on spans by nodeId, traceId, timestamps. Upgrade and migration -- Graph configuration is sourced from Teams service; `/api/graph` is GET-only. Node state and graph variables persist in Postgres (`GraphNodeState`, `GraphVariable`); filesystem-backed graph storage is retired. +- Graph configuration and variables are sourced from the Teams service via gRPC; `/api/graph` is GET-only. Node state is in-memory only; filesystem-backed graph storage is retired. - UI dependency on change streams is retired alongside Mongo. - MCP heartbeat/backoff planned; non-breaking once added. - See: docs/graph/fs-store.md (legacy filesystem format reference) diff --git a/docs/security/vault.md b/docs/security/vault.md index 1959ba831..1baa22d36 100644 --- a/docs/security/vault.md +++ b/docs/security/vault.md @@ -20,7 +20,7 @@ Workspace env vars - `env: Array<{ key: string; value: string | SecretRef | VariableRef }>` - Plain strings are injected verbatim. - Vault references use `{ kind: 'vault', path: 'services/slack', key: 'BOT_TOKEN', mount?: 'secret' }`. - - Graph variable references use `{ kind: 'var', name: 'SLACK_BOT_TOKEN', default?: 'fallback' }`. + - Graph variable references use `{ kind: 'var', name: 'SLACK_BOT_TOKEN', default?: 'fallback' }` (resolved via Teams-managed graph variables). - On provision, the server resolves vault-backed entries and injects values into the container environment. - Legacy compatibility removed: envRefs is no longer supported. Providing envRefs will fail validation. A legacy plain env map may still be accepted by the server for convenience, but new configurations should use the array form. @@ -28,7 +28,7 @@ GitHub Clone Repo auth - New: `token?: string | SecretRef | VariableRef` - Plain strings are used directly. - Vault references are resolved server-side before cloning. - - Variable references allow graph variables to supply tokens. + - Variable references allow Teams-managed graph variables to supply tokens. - Fallbacks: if not provided or resolution fails, server falls back to `ConfigService.githubToken`. - Backward compatibility: legacy `authRef` remains supported at runtime but is not shown in templates. diff --git a/docs/ui/config-views.md b/docs/ui/config-views.md index a3b30269b..b02c3bc84 100644 --- a/docs/ui/config-views.md +++ b/docs/ui/config-views.md @@ -2,14 +2,12 @@ Config Views (UI) Overview - Custom Config Views render configuration sections for builder nodes. -- Views are registered in a typed registry and resolved by template name and mode (static or dynamic). +- Views are registered in a typed registry and resolved by template name (static config only; dynamic views are not wired). How to add a new Config View -- Create a component implementing either: - - StaticConfigViewProps (for static config saved in graph), or - - DynamicConfigViewProps (for runtime-driven config schema). +- Create a component implementing StaticConfigViewProps (for static config saved in graph). - Register it in the default config views registry: - - registerConfigView({ template: '', mode: 'static' | 'dynamic', component: YourComponent }) + - registerConfigView({ template: '', mode: 'static', component: YourComponent }) Event semantics - onChange(next): emit the full new value for the section; parent replaces that section and autosaves. diff --git a/docs/ui/graph/README.md b/docs/ui/graph/README.md index f59cad39f..82d7aa23d 100644 --- a/docs/ui/graph/README.md +++ b/docs/ui/graph/README.md @@ -4,23 +4,19 @@ This UI targets a single-graph runtime with REST endpoints under the /graph pref Key ideas - Single-graph model: node-level endpoints are scoped as /graph/nodes/:nodeId -- Capabilities drive UI controls: - - provisionable → Start/Stop buttons - - pausable → Pause/Resume toggle - - staticConfigurable → custom static ConfigView rendered from registry - - dynamicConfigurable → custom dynamic ConfigView rendered from registry +- Palette schema exposes kind/ports; UI uses built-in config views for known templates. - Live status via socket.io; no polling Endpoints (reference) - GET /graph/templates - GET /graph/nodes/:nodeId/status -- POST /graph/nodes/:nodeId/actions body: { action: 'provision' | 'deprovision' | 'pause' | 'resume' } -- GET /graph/nodes/:nodeId/dynamic-config/schema (UI may fallback to a legacy internal path) +- POST /graph/nodes/:nodeId/actions body: { action: 'provision' | 'deprovision' } +- POST /graph/nodes/:nodeId/discover-tools Socket updates - Socket: default namespace (no custom path) - Event: node_status -- Payload: { nodeId, isPaused?, provisionStatus?, dynamicConfigReady?, updatedAt? } +- Payload: { nodeId, provisionStatus?, updatedAt? } - The UI subscribes per-node and reconciles socket events into React Query cache. See also diff --git a/docs/ui/graph/index.md b/docs/ui/graph/index.md index 9a74d64e6..78901b884 100644 --- a/docs/ui/graph/index.md +++ b/docs/ui/graph/index.md @@ -1,10 +1,10 @@ # Graph UI Builder Data flow -- TemplatesProvider loads templates from `/graph/templates` (alias of `/api/templates`). Components consume capabilities to render controls. +- TemplatesProvider loads templates from `/graph/templates` (alias of `/api/templates`). Components use kind/ports metadata (capability flags are not included). - Initial node status fetched via `GET /graph/nodes/:id/status`. - Realtime updates: listen to Socket.IO on the default namespace for `node_status` events. Do not poll when sockets are available. -- For dynamic-configurable nodes (e.g., MCP server), fetch JSON Schema via `GET /graph/nodes/:id/dynamic-config/schema` and render a dynamic form when `dynamicConfigReady` is true. +- For MCP server nodes, refresh tool lists via `POST /graph/nodes/:id/discover-tools` and use the response to update tool selection UI. - Refer to docs/graph/status-updates.md for event shapes and sequencing. Configuration diff --git a/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts b/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts index 17badd536..fe66d283e 100644 --- a/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts +++ b/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts @@ -150,9 +150,7 @@ const createBootstrapStubs = (): BootstrapStubs => { updatedAt: new Date(0).toISOString(), nodes: [], edges: [], - variables: [], }), - upsertNodeState: vi.fn().mockResolvedValue(undefined), } satisfies Record; const templateRegistryStub = { diff --git a/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts b/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts index d5b0192de..88affaf6c 100644 --- a/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts +++ b/packages/platform-server/__tests__/agents.persistence.metrics_titles.test.ts @@ -76,19 +76,19 @@ describe('AgentsPersistenceService metrics and agent titles', () => { const teamsClient = createTeamsClientStub({ agents: [ create(AgentSchema, { - id: 'agent-configured', + meta: { id: 'agent-configured' }, title: ' Configured Agent ', description: '', config: create(AgentConfigSchema, { name: ' Casey ', role: ' Lead Engineer ' }), }), create(AgentSchema, { - id: 'agent-profile', + meta: { id: 'agent-profile' }, title: '', description: '', config: create(AgentConfigSchema, { name: ' Delta ', role: ' Support ' }), }), - create(AgentSchema, { id: 'agent-template', title: '', description: '' }), - create(AgentSchema, { id: 'agent-assigned', title: 'Assigned Only', description: '' }), + create(AgentSchema, { meta: { id: 'agent-template' }, title: '', description: '' }), + create(AgentSchema, { meta: { id: 'agent-assigned' }, title: 'Assigned Only', description: '' }), ], }); const threadConfigured = (await stub.thread.create({ data: { alias: 'config' } })).id; diff --git a/packages/platform-server/__tests__/graph.mcp.integration.test.ts b/packages/platform-server/__tests__/graph.mcp.integration.test.ts index ba3b5f86b..5c600cabc 100644 --- a/packages/platform-server/__tests__/graph.mcp.integration.test.ts +++ b/packages/platform-server/__tests__/graph.mcp.integration.test.ts @@ -6,7 +6,6 @@ import { DOCKER_CLIENT } from '../src/infra/container/dockerClient.token'; import { ConfigService } from '../src/core/services/config.service.js'; import { EnvService } from '../src/env/env.service'; import { VaultService } from '../src/vault/vault.service'; -import { NodeStateService } from '../src/graph/nodeState.service'; import { ContainerRegistry } from '../src/infra/container/container.registry'; import { NcpsKeyService } from '../src/infra/ncps/ncpsKey.service'; import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; @@ -186,7 +185,6 @@ describe('Graph MCP integration', () => { { provide: LLMProvisioner, useClass: StubLLMProvisioner }, { provide: NcpsKeyService, useValue: { getKeysForInjection: () => [] } }, { provide: ContainerRegistry, useValue: { updateLastUsed: async () => {}, registerStart: async () => {}, markStopped: async () => {} } }, - { provide: NodeStateService, useValue: { upsertNodeState: async () => {}, getSnapshot: () => undefined } }, TemplateRegistry, LiveGraphRuntime, GraphRepository, @@ -206,11 +204,10 @@ describe('Graph MCP integration', () => { const moduleRef = module.get(ModuleRef); const templateRegistry = buildTemplateRegistry({ moduleRef }); - class GraphRepoStub implements Pick { + class GraphRepoStub implements Pick { async initIfNeeded(): Promise {} async get(): Promise { return null; } async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} } const resolver = { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) }; diff --git a/packages/platform-server/__tests__/graph.module.di.smoke.test.ts b/packages/platform-server/__tests__/graph.module.di.smoke.test.ts index e10dbefab..39ba1739f 100644 --- a/packages/platform-server/__tests__/graph.module.di.smoke.test.ts +++ b/packages/platform-server/__tests__/graph.module.di.smoke.test.ts @@ -193,7 +193,6 @@ if (!shouldRunDbTests) { initIfNeeded: vi.fn().mockResolvedValue(undefined), get: vi.fn().mockResolvedValue(null), upsert: vi.fn().mockResolvedValue({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] }), - upsertNodeState: vi.fn().mockResolvedValue(undefined), } satisfies Partial; const builder = Test.createTestingModule({ @@ -230,7 +229,6 @@ if (!shouldRunDbTests) { builder.overrideProvider(TemplateRegistry).useFactory(() => templateRegistryStub as TemplateRegistry); builder.overrideProvider(GraphRepository).useFactory(() => graphRepositoryStub as GraphRepository); builder.overrideProvider(GraphSocketGateway).useValue({ - emitNodeState: vi.fn(), emitThreadCreated: vi.fn(), emitThreadUpdated: vi.fn(), emitRunEvent: vi.fn(), diff --git a/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts b/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts index 0fcb7e70c..c12e35b29 100644 --- a/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts +++ b/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts @@ -12,7 +12,6 @@ type GatewayTestContext = { chunk: Handler; terminal: Handler; reminder: Handler; - nodeState: Handler<{ nodeId: string; state: Record; updatedAtMs?: number }>; threadCreated: Handler<{ id: string }>; threadUpdated: Handler<{ id: string }>; messageCreated: Handler<{ threadId: string; message: { id: string } }>; @@ -30,7 +29,6 @@ function createGatewayTestContext(): GatewayTestContext { chunk: null, terminal: null, reminder: null, - nodeState: null, threadCreated: null, threadUpdated: null, messageCreated: null, @@ -43,7 +41,6 @@ function createGatewayTestContext(): GatewayTestContext { chunk: vi.fn(), terminal: vi.fn(), reminder: vi.fn(), - nodeState: vi.fn(), threadCreated: vi.fn(), threadUpdated: vi.fn(), messageCreated: vi.fn(), @@ -58,7 +55,6 @@ function createGatewayTestContext(): GatewayTestContext { | 'subscribeToToolOutputChunk' | 'subscribeToToolOutputTerminal' | 'subscribeToReminderCount' - | 'subscribeToNodeState' | 'subscribeToThreadCreated' | 'subscribeToThreadUpdated' | 'subscribeToMessageCreated' @@ -82,10 +78,6 @@ function createGatewayTestContext(): GatewayTestContext { handlers.reminder = listener; return disposers.reminder; }, - subscribeToNodeState: (listener) => { - handlers.nodeState = listener; - return disposers.nodeState; - }, subscribeToThreadCreated: (listener) => { handlers.threadCreated = listener; return disposers.threadCreated; @@ -242,12 +234,6 @@ describe('GraphSocketGateway event bus integration', () => { expect(scheduleAncestors).toHaveBeenCalledWith('thread-1'); }); - it('forwards node_state events to emitNodeState', () => { - const spy = vi.spyOn(ctx.gateway, 'emitNodeState'); - ctx.handlers.nodeState?.({ nodeId: 'node-1', state: { value: 1 }, updatedAtMs: 10 }); - expect(spy).toHaveBeenCalledWith('node-1', { value: 1 }, 10); - }); - it('emits thread and message events', () => { const threadCreated = vi.spyOn(ctx.gateway, 'emitThreadCreated'); const threadUpdated = vi.spyOn(ctx.gateway, 'emitThreadUpdated'); @@ -296,7 +282,6 @@ describe('GraphSocketGateway event bus integration', () => { expect(ctx.disposers.chunk).toHaveBeenCalledTimes(1); expect(ctx.disposers.terminal).toHaveBeenCalledTimes(1); expect(ctx.disposers.reminder).toHaveBeenCalledTimes(1); - expect(ctx.disposers.nodeState).toHaveBeenCalledTimes(1); expect(ctx.disposers.threadCreated).toHaveBeenCalledTimes(1); expect(ctx.disposers.threadUpdated).toHaveBeenCalledTimes(1); expect(ctx.disposers.messageCreated).toHaveBeenCalledTimes(1); diff --git a/packages/platform-server/__tests__/helpers/eventsBus.stub.ts b/packages/platform-server/__tests__/helpers/eventsBus.stub.ts index fc03c87cc..bdce86a09 100644 --- a/packages/platform-server/__tests__/helpers/eventsBus.stub.ts +++ b/packages/platform-server/__tests__/helpers/eventsBus.stub.ts @@ -2,7 +2,6 @@ import { vi } from 'vitest'; export type EventsBusStub = { publishEvent: ReturnType; - emitNodeState: ReturnType; emitThreadCreated: ReturnType; emitThreadUpdated: ReturnType; emitMessageCreated: ReturnType; @@ -16,7 +15,6 @@ export type EventsBusStub = { subscribeToToolOutputChunk: ReturnType; subscribeToToolOutputTerminal: ReturnType; subscribeToReminderCount: ReturnType; - subscribeToNodeState: ReturnType; subscribeToThreadCreated: ReturnType; subscribeToThreadUpdated: ReturnType; subscribeToMessageCreated: ReturnType; @@ -29,7 +27,6 @@ export function createEventsBusStub(): EventsBusStub { const disposer = () => vi.fn(); return { publishEvent: vi.fn(async () => null), - emitNodeState: vi.fn(), emitThreadCreated: vi.fn(), emitThreadUpdated: vi.fn(), emitMessageCreated: vi.fn(), @@ -43,7 +40,6 @@ export function createEventsBusStub(): EventsBusStub { subscribeToToolOutputChunk: vi.fn(() => disposer()), subscribeToToolOutputTerminal: vi.fn(() => disposer()), subscribeToReminderCount: vi.fn(() => disposer()), - subscribeToNodeState: vi.fn(() => disposer()), subscribeToThreadCreated: vi.fn(() => disposer()), subscribeToThreadUpdated: vi.fn(() => disposer()), subscribeToMessageCreated: vi.fn(() => disposer()), diff --git a/packages/platform-server/__tests__/helpers/runEvents.stub.ts b/packages/platform-server/__tests__/helpers/runEvents.stub.ts index fe98dfb2b..58d449d72 100644 --- a/packages/platform-server/__tests__/helpers/runEvents.stub.ts +++ b/packages/platform-server/__tests__/helpers/runEvents.stub.ts @@ -62,12 +62,10 @@ export function createEventsBusStub() { emitThreadMetrics: vi.fn(), emitThreadMetricsAncestors: vi.fn(), emitReminderCount: vi.fn(), - emitNodeState: vi.fn(), emitThreadCreated: vi.fn(), emitThreadUpdated: vi.fn(), emitMessageCreated: vi.fn(), subscribeToReminderCount: vi.fn(() => disposer()), - subscribeToNodeState: vi.fn(() => disposer()), subscribeToThreadCreated: vi.fn(() => disposer()), subscribeToThreadUpdated: vi.fn(() => disposer()), subscribeToMessageCreated: vi.fn(() => disposer()), diff --git a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts index 229fcb955..7730a01bf 100644 --- a/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts +++ b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts @@ -32,41 +32,65 @@ export const createTeamsClientStub = (options?: TeamsClientStubOptions): TeamsGr const memoryBuckets = options?.memoryBuckets ?? []; const attachments = options?.attachments ?? []; - const paginate = (items: T[], page: number, perPage: number) => { - const start = Math.max(0, (page - 1) * perPage); + const readOffset = (token?: string) => { + if (!token) return 0; + const offset = Number.parseInt(token, 10); + if (!Number.isFinite(offset) || offset < 0) return 0; + return offset; + }; + + const paginate = (items: T[], pageSize: number, pageToken?: string) => { + const size = pageSize > 0 ? pageSize : items.length; + const start = readOffset(pageToken); + const nextOffset = start + size; return { - items: items.slice(start, start + perPage), - page, - perPage, - total: BigInt(items.length), + items: items.slice(start, start + size), + nextPageToken: nextOffset < items.length ? String(nextOffset) : '', }; }; - const listAgents = options?.listAgents ?? (async (request: { page: number; perPage: number }) => paginate(agents, request.page, request.perPage)); + const listAgents = options?.listAgents ?? + (async (request: { pageSize: number; pageToken?: string }) => { + const { items, nextPageToken } = paginate(agents, request.pageSize, request.pageToken); + return { agents: items, nextPageToken }; + }); const listTools = options?.listTools ?? - (async (request: { page: number; perPage: number; type?: Tool['type'] }) => { + (async (request: { pageSize: number; pageToken?: string; type?: Tool['type'] }) => { const shouldFilter = typeof request.type === 'number' && request.type !== ToolType.UNSPECIFIED; - return paginate( + const { items, nextPageToken } = paginate( shouldFilter ? tools.filter((tool) => tool.type === request.type) : tools, - request.page, - request.perPage, + request.pageSize, + request.pageToken, ); + return { tools: items, nextPageToken }; + }); + const listMcpServers = options?.listMcpServers ?? + (async (request: { pageSize: number; pageToken?: string }) => { + const { items, nextPageToken } = paginate(mcps, request.pageSize, request.pageToken); + return { mcpServers: items, nextPageToken }; }); - const listMcpServers = options?.listMcpServers ?? (async (request: { page: number; perPage: number }) => paginate(mcps, request.page, request.perPage)); const listWorkspaceConfigurations = options?.listWorkspaceConfigurations ?? - (async (request: { page: number; perPage: number }) => paginate(workspaces, request.page, request.perPage)); + (async (request: { pageSize: number; pageToken?: string }) => { + const { items, nextPageToken } = paginate(workspaces, request.pageSize, request.pageToken); + return { workspaceConfigurations: items, nextPageToken }; + }); const listMemoryBuckets = options?.listMemoryBuckets ?? - (async (request: { page: number; perPage: number }) => paginate(memoryBuckets, request.page, request.perPage)); + (async (request: { pageSize: number; pageToken?: string }) => { + const { items, nextPageToken } = paginate(memoryBuckets, request.pageSize, request.pageToken); + return { memoryBuckets: items, nextPageToken }; + }); const listAttachments = options?.listAttachments ?? - (async (request: { page: number; perPage: number; kind?: Attachment['kind']; sourceType?: Attachment['sourceType']; targetType?: Attachment['targetType'] }) => - paginate( + (async (request: { pageSize: number; pageToken?: string; kind?: Attachment['kind']; sourceType?: Attachment['sourceType']; targetType?: Attachment['targetType'] }) => { + const { items, nextPageToken } = paginate( attachments.filter((attachment) => attachment.kind === request.kind && attachment.sourceType === request.sourceType && attachment.targetType === request.targetType), - request.page, - request.perPage, - )); + request.pageSize, + request.pageToken, + ); + return { attachments: items, nextPageToken }; + }); return { listAgents, diff --git a/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts b/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts index c68a89515..b6469f768 100644 --- a/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts +++ b/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts @@ -99,7 +99,6 @@ describe('LiveGraphRuntime -> Agent config propagation', () => { async initIfNeeded(): Promise {} async get(): Promise { return null; } async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} } const resolver = { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) }; const runtime = new LiveGraphRuntime(registry, new StubRepo(), moduleRef, resolver as any); diff --git a/packages/platform-server/__tests__/localMcpServer.heartbeat.test.ts b/packages/platform-server/__tests__/localMcpServer.heartbeat.test.ts index 4f2aeb868..2ac9ba7d5 100644 --- a/packages/platform-server/__tests__/localMcpServer.heartbeat.test.ts +++ b/packages/platform-server/__tests__/localMcpServer.heartbeat.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; import { PassThrough } from 'node:stream'; -import { createModuleRefStub } from './helpers/module-ref.stub'; import { WorkspaceProviderStub, WorkspaceNodeStub } from './helpers/workspace-provider.stub'; import type { WorkspaceStdioSessionRequest } from '../src/workspace/runtime/workspace.runtime.provider'; @@ -96,7 +95,7 @@ describe('LocalMCPServer heartbeat behavior', () => { const envStub = { resolveEnvItems: async () => ({}), resolveProviderEnv: async () => ({}) } as any; const provider = new BlockingWorkspaceProvider(); const workspaceNode = new WorkspaceNodeStub(provider); - const server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + const server = new LocalMCPServerNode(envStub, {} as any); server.setContainerProvider(workspaceNode as unknown as typeof server['containerProvider']); await server.setConfig({ namespace: 'mock', command: 'ignored', heartbeatIntervalMs: 100 } as any); diff --git a/packages/platform-server/__tests__/localMcpServer.test.ts b/packages/platform-server/__tests__/localMcpServer.test.ts index 2a3aea369..6d3a32d67 100644 --- a/packages/platform-server/__tests__/localMcpServer.test.ts +++ b/packages/platform-server/__tests__/localMcpServer.test.ts @@ -3,7 +3,6 @@ import { PassThrough } from 'node:stream'; import { McpServerConfig } from '../src/mcp/types.js'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; -import { createModuleRefStub } from './helpers/module-ref.stub'; import { WorkspaceHandle } from '../src/workspace/workspace.handle'; import { WorkspaceProvider, @@ -187,7 +186,7 @@ describe('LocalMCPServer (mock)', () => { resolveEnvItems: vi.fn(), }; const configStub = { mcpToolsStaleTimeoutMs: 0 } as const; - server = new LocalMCPServerNode(envStub as any, configStub as any, createModuleRefStub()); + server = new LocalMCPServerNode(envStub as any, configStub as any); const mockWorkspaceNode = { provide: async (threadId: string) => { let handle = handles.get(threadId); @@ -219,9 +218,10 @@ describe('LocalMCPServer (mock)', () => { await server.deprovision(); }); - it('lists tools when enabledTools are provided', async () => { - expect(server.listTools()).toEqual([]); - await server.setState({ mcp: { enabledTools: ['echo'] } as any }); + it('lists tools after discovery', async () => { + (server as any).toolsDiscovered = false; + (server as any).toolsCache = null; + await server.discoverTools(); const tools = server.listTools(); expect(tools.find((t) => String(t.name).endsWith('_echo'))).toBeTruthy(); }); @@ -263,10 +263,13 @@ describe('LocalMCPServer (mock)', () => { (server as any).on('mcp.tools_updated', (p: { tools: any[]; updatedAt: number }) => { lastPayload = p; }); - (server as any).preloadCachedTools([{ name: 'pre' }], Date.now()); + await server.setConfig({ ...server.config, toolFilter: { mode: 'deny', rules: [] } } as any); expect(lastPayload).toBeTruthy(); expect(Array.isArray(lastPayload!.tools)).toBe(true); - await (server as any).discoverTools(); + lastPayload = null; + (server as any).toolsDiscovered = false; + (server as any).toolsCache = null; + await server.discoverTools(); expect(lastPayload).toBeTruthy(); expect(typeof lastPayload!.updatedAt).toBe('number'); }); diff --git a/packages/platform-server/__tests__/manage.tool.test.ts b/packages/platform-server/__tests__/manage.tool.test.ts index 4443d15c8..fc1e7a17c 100644 --- a/packages/platform-server/__tests__/manage.tool.test.ts +++ b/packages/platform-server/__tests__/manage.tool.test.ts @@ -484,7 +484,6 @@ describe('ManageTool graph wiring', () => { upsert: async () => { throw new Error('not-implemented'); }, - upsertNodeState: async () => {}, }, }, { provide: ReferenceResolverService, useValue: createReferenceResolverStub().stub }, diff --git a/packages/platform-server/__tests__/mcp-lifecycle-changes.test.ts b/packages/platform-server/__tests__/mcp-lifecycle-changes.test.ts index b001c36c1..7acac58c2 100644 --- a/packages/platform-server/__tests__/mcp-lifecycle-changes.test.ts +++ b/packages/platform-server/__tests__/mcp-lifecycle-changes.test.ts @@ -1,14 +1,13 @@ import { describe, it, expect } from 'vitest'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; import { McpServerConfig } from '../src/mcp/types.js'; -import { createModuleRefStub } from './helpers/module-ref.stub'; import { WorkspaceProviderStub, WorkspaceNodeStub } from './helpers/workspace-provider.stub'; describe('MCP Lifecycle Changes', () => { const envStub = { resolveEnvItems: async () => ({}), resolveProviderEnv: async () => ({}) } as any; it('supports threadId parameter in callTool method', async () => { - const server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + const server = new LocalMCPServerNode(envStub, {} as any); const provider = new WorkspaceProviderStub(); const workspaceNode = new WorkspaceNodeStub(provider); server.setContainerProvider(workspaceNode as unknown as typeof server['containerProvider']); @@ -22,7 +21,7 @@ describe('MCP Lifecycle Changes', () => { }); it('has discoverTools method for initial tool discovery', async () => { - const server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + const server = new LocalMCPServerNode(envStub, {} as any); expect(typeof server.discoverTools).toBe('function'); try { @@ -33,7 +32,7 @@ describe('MCP Lifecycle Changes', () => { }); it('demonstrates new vs old lifecycle pattern', () => { - const server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + const server = new LocalMCPServerNode(envStub, {} as any); expect(typeof server.discoverTools).toBe('function'); expect(server.callTool.length >= 2).toBe(true); expect((server as any).client).toBeUndefined(); diff --git a/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts b/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts deleted file mode 100644 index 9903f835d..000000000 --- a/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Test } from '@nestjs/testing'; -import { buildTemplateRegistry } from '../src/templates'; -import { DOCKER_CLIENT } from '../src/infra/container/dockerClient.token'; -import { ConfigService } from '../src/core/services/config.service.js'; -import { EnvService } from '../src/env/env.service'; -import { VaultService } from '../src/vault/vault.service'; -import { NodeStateService } from '../src/graph/nodeState.service'; -import { ContainerRegistry } from '../src/infra/container/container.registry'; -import { NcpsKeyService } from '../src/infra/ncps/ncpsKey.service'; -import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; -import { ModuleRef } from '@nestjs/core'; -import { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; -import { GraphRepository } from '../src/graph/graph.repository'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import type { GraphDefinition, PersistedGraph } from '../src/shared/types/graph.types'; -import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; -import { RunSignalsRegistry } from '../src/agents/run-signals.service'; -import { EventsBusService } from '../src/events/events-bus.service'; -import { createEventsBusStub } from './helpers/eventsBus.stub'; -import { createDockerClientStub } from './helpers/dockerClient.stub'; -import { ReferenceResolverService } from '../src/utils/reference-resolver.service'; -import { WorkspaceProvider } from '../src/workspace/providers/workspace.provider'; -import { WorkspaceProviderStub } from './helpers/workspace-provider.stub'; -class StubConfigService extends ConfigService { - constructor() { - super(); - this.init({ - githubAppId: 'test', - githubAppPrivateKey: 'test', - githubInstallationId: 'test', - openaiApiKey: 'test', - llmProvider: 'openai', - litellmBaseUrl: 'http://localhost:4000', - litellmMasterKey: 'sk-test', - openaiBaseUrl: undefined, - githubToken: 'test', - graphRepoPath: './data/graph', - graphBranch: 'main', - graphAuthorName: undefined, - graphAuthorEmail: undefined, - graphLockTimeoutMs: 5000, - vaultEnabled: false, - vaultAddr: undefined, - vaultToken: undefined, - dockerMirrorUrl: 'http://registry-mirror:5000', - nixAllowedChannels: ['nixpkgs-unstable', 'nixos-24.11'], - nixHttpTimeoutMs: 5000, - nixCacheTtlMs: 300000, - nixCacheMax: 500, - mcpToolsStaleTimeoutMs: 0, - ncpsEnabled: false, - ncpsUrl: 'http://ncps:8501', - ncpsUrlServer: 'http://ncps:8501', - ncpsUrlContainer: 'http://ncps:8501', - ncpsPubkeyPath: '/pubkey', - ncpsFetchTimeoutMs: 3000, - ncpsRefreshIntervalMs: 600000, - ncpsStartupMaxRetries: 8, - ncpsRetryBackoffMs: 500, - ncpsRetryBackoffFactor: 2, - ncpsAllowStartWithoutKey: true, - ncpsCaBundle: undefined, - ncpsRotationGraceMinutes: 0, - ncpsAuthHeader: undefined, - ncpsAuthToken: undefined, - agentsDatabaseUrl: 'postgres://localhost:5432/agents', - corsOrigins: [], - }); - } -} -class StubVaultService extends VaultService { - override async getSecret(): Promise { - return undefined; - } -} -class StubLLMProvisioner extends LLMProvisioner { - async init(): Promise {} - async getLLM(): Promise<{ call: (messages: unknown) => Promise<{ text: string; output: unknown[] }> }> { - return { call: async () => ({ text: 'ok', output: [] }) }; - } - async teardown(): Promise {} -} - -describe('Boot respects MCP enabledTools from persisted state', () => { - it('agent registers only enabled MCP tools on load', async () => { - const module = await Test.createTestingModule({ - providers: [ - { provide: DOCKER_CLIENT, useValue: createDockerClientStub() }, - { provide: ConfigService, useClass: StubConfigService }, - EnvService, - { - provide: ReferenceResolverService, - useValue: { - resolve: async (input: unknown) => ({ output: input, report: {} as unknown }), - }, - }, - { provide: VaultService, useClass: StubVaultService }, - { provide: LLMProvisioner, useClass: StubLLMProvisioner }, - { provide: NcpsKeyService, useValue: { getKeysForInjection: () => [] } }, - { provide: ContainerRegistry, useValue: { updateLastUsed: async () => {}, registerStart: async () => {}, markStopped: async () => {} } }, - { provide: WorkspaceProvider, useClass: WorkspaceProviderStub }, - { provide: GraphSocketGateway, useValue: { emitNodeState: (_id: string, _state: Record) => {} } }, - NodeStateService, - TemplateRegistry, - LiveGraphRuntime, - GraphRepository, - { provide: EventsBusService, useValue: createEventsBusStub() as unknown as EventsBusService }, - { - provide: AgentsPersistenceService, - useValue: { - beginRun: async () => ({ runId: 't' }), - recordInjected: async () => ({ messageIds: [] }), - completeRun: async () => {}, - }, - }, - RunSignalsRegistry, - ], - }).compile(); - - const moduleRef = module.get(ModuleRef); - - const templateRegistry = buildTemplateRegistry({ moduleRef }); - - const nowIso = new Date().toISOString(); - const persisted: PersistedGraph = { - name: 'main', - version: 1, - updatedAt: nowIso, - nodes: [ - { id: 'container', template: 'workspace' }, - { id: 'agent', template: 'agent' }, - { - id: 'mcp', - template: 'mcpServer', - config: { namespace: 'ns', command: 'echo "mock"' }, - state: { - mcp: { - tools: [ - { name: 'a', description: 'A', inputSchema: { type: 'object' } }, - { name: 'b', description: 'B', inputSchema: { type: 'object' } }, - ], - toolsUpdatedAt: Date.now(), - enabledTools: ['a'], - }, - }, - }, - ], - edges: [ - { source: 'container', sourceHandle: '$self', target: 'mcp', targetHandle: 'workspace' }, - { source: 'agent', sourceHandle: 'mcp', target: 'mcp', targetHandle: '$self' }, - ], - }; - - class GraphRepoStub implements Pick { - async initIfNeeded(): Promise {} - async get(_name: string): Promise { return persisted; } - async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} - } - - const resolver = { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) }; - const runtime = new LiveGraphRuntime( - templateRegistry, - new GraphRepoStub() as unknown as GraphRepository, - moduleRef, - resolver as any, - ); - const loaded = await runtime.load(); - expect(loaded.applied).toBe(true); - - // Find agent instance and inspect registered tools - const agentLive = runtime.getNodes().find((n) => n.template === 'agent'); - expect(agentLive).toBeTruthy(); - const agent = agentLive!.instance as any; - const names = Array.from(agent.tools)?.map((t: any) => t.name) ?? []; - expect(names).toContain('ns_a'); - expect(names).not.toContain('ns_b'); - }); -}); diff --git a/packages/platform-server/__tests__/mcp.listTools.snapshot_fallback.test.ts b/packages/platform-server/__tests__/mcp.listTools.snapshot_fallback.test.ts index 142c218be..b6f9ec4e6 100644 --- a/packages/platform-server/__tests__/mcp.listTools.snapshot_fallback.test.ts +++ b/packages/platform-server/__tests__/mcp.listTools.snapshot_fallback.test.ts @@ -1,51 +1,50 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { z } from 'zod'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; -import { createModuleRefStub } from './helpers/module-ref.stub'; -import { WorkspaceProviderStub, WorkspaceNodeStub } from './helpers/workspace-provider.stub'; +import { LocalMCPServerTool } from '../src/nodes/mcp/localMcpServer.tool'; -const makeEnvService = () => ({ resolveEnvItems: vi.fn(async () => ({})), resolveProviderEnv: vi.fn(async () => ({})) }); - -describe('LocalMCPServerNode listTools: snapshot-first, fallback-to-setState, namespacing', () => { +describe('LocalMCPServerNode tool filtering and snapshots', () => { let server: LocalMCPServerNode; - let logger: { log: ReturnType; error: ReturnType }; + + const seedTools = () => { + const toolA = new LocalMCPServerTool('alpha', 'A', z.object({}).strict(), server); + const toolB = new LocalMCPServerTool('beta', 'B', z.object({}).strict(), server); + (server as any).toolsCache = [toolA, toolB]; + (server as any).toolsDiscovered = true; + }; beforeEach(async () => { - logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() }; - const nodeStateService = { getSnapshot: vi.fn((_id: string) => undefined) } as any; // snapshot not ready - const moduleRef = createModuleRefStub({ get: () => nodeStateService }); - server = new LocalMCPServerNode(makeEnvService() as any, {} as any, moduleRef); + const envStub = { resolveEnvItems: vi.fn(async () => ({})), resolveProviderEnv: vi.fn(async () => ({})) } as any; + server = new LocalMCPServerNode(envStub, {} as any); (server as any).init({ nodeId: 'node-x' }); - const provider = new WorkspaceProviderStub(); - const workspaceNode = new WorkspaceNodeStub(provider); - (server as any).setContainerProvider(workspaceNode); await server.setConfig({ namespace: 'ns' } as any); - (server as any).preloadCachedTools( - [ - { name: 'a', description: 'A', inputSchema: { type: 'object' } }, - { name: 'b', description: 'B', inputSchema: { type: 'object' } }, - ], - Date.now(), - ); - (server as any).logger = logger; - (server as any).nodeStateService = nodeStateService; + seedTools(); }); - it('returns [] when enabledTools is not provided', () => { + it('returns namespaced tools when no filter is set', () => { const tools = server.listTools(); - expect(tools).toEqual([]); + expect(tools.map((t) => t.name)).toEqual(['ns_alpha', 'ns_beta']); }); - it('falls back to setState enabledTools when snapshot is undefined', async () => { - await server.setState({ mcp: { enabledTools: ['a', 'c'] } as any }); - const tools = server.listTools(); - expect(tools.map((t) => t.name)).toEqual(['ns_a']); - expect(logger.log.mock.calls.find((c: any[]) => String(c[0]).includes('unknown tool'))).toBeTruthy(); + it('applies allow/deny toolFilter rules against raw names', async () => { + await server.setConfig({ + namespace: 'ns', + toolFilter: { mode: 'allow', rules: [{ pattern: 'alpha' }] }, + } as any); + expect(server.listTools().map((t) => t.name)).toEqual(['ns_alpha']); + + await server.setConfig({ + namespace: 'ns', + toolFilter: { mode: 'deny', rules: [{ pattern: 'beta' }] }, + } as any); + expect(server.listTools().map((t) => t.name)).toEqual(['ns_alpha']); }); - it('accepts raw names from snapshot and maps to runtime namespaced form', async () => { - const ns = { getSnapshot: vi.fn((_id: string) => ({ mcp: { enabledTools: ['a'] } })) } as any; - (server as any).nodeStateService = ns; - const tools = server.listTools(); - expect(tools.map((t) => t.name)).toEqual(['ns_a']); + it('snapshots include updatedAt and namespaced tool names', () => { + const ts = Date.now(); + (server as any).lastToolsUpdatedAt = ts; + const snapshot = server.getToolsSnapshot(); + expect(snapshot.updatedAt).toBe(ts); + expect(snapshot.tools.map((tool) => tool.name)).toEqual(['ns_alpha', 'ns_beta']); }); }); diff --git a/packages/platform-server/__tests__/mcp.preload.stale.persist.test.ts b/packages/platform-server/__tests__/mcp.preload.stale.persist.test.ts index 690333769..a5eaddabe 100644 --- a/packages/platform-server/__tests__/mcp.preload.stale.persist.test.ts +++ b/packages/platform-server/__tests__/mcp.preload.stale.persist.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; -import { createModuleRefStub } from './helpers/module-ref.stub'; +import { LocalMCPServerTool } from '../src/nodes/mcp/localMcpServer.tool'; import { WorkspaceProviderStub, WorkspaceNodeStub } from './helpers/workspace-provider.stub'; describe('LocalMCPServer preload + staleness + persist', () => { @@ -9,7 +10,7 @@ describe('LocalMCPServer preload + staleness + persist', () => { beforeEach(async () => { const envStub = { resolveEnvItems: async () => ({}), resolveProviderEnv: async () => ({}) } as any; - server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + server = new LocalMCPServerNode(envStub, {} as any); await server.setConfig({ namespace: 'x', command: 'echo' } as any); const provider = new WorkspaceProviderStub(); const workspaceNode = new WorkspaceNodeStub(provider); @@ -17,17 +18,29 @@ describe('LocalMCPServer preload + staleness + persist', () => { (server as any).on('mcp.tools_updated', (p: { tools: any[]; updatedAt: number }) => { lastUpdate = p; }); }); - it('preloads cached tools and persists after discovery', async () => { - // Preload - (server as any).preloadCachedTools([{ name: 'cached', description: 'd', inputSchema: { type: 'object' } } as any], Date.now() - 1000); - // Force discovery by marking stale + it('refreshes stale tools during provisioning', async () => { + const cached = new LocalMCPServerTool('cached', 'd', z.object({}).strict(), server); + (server as any).toolsCache = [cached]; + (server as any).toolsDiscovered = true; + (server as any).lastToolsUpdatedAt = Date.now() - 1000; + await server.setConfig({ namespace: 'x', command: 'echo', staleTimeoutMs: 1 } as any); - // Stub discoverTools to avoid docker - (server as any).discoverTools = async function() { (this as any).preloadCachedTools([{ name: 'fresh', description: 'd', inputSchema: { type: 'object' } }], Date.now()); return (this as any).listTools(); }; - await (server as any).provision(); - await server.setState({ mcp: { enabledTools: ['fresh'] } as any }); + lastUpdate = null; + + (server as any).discoverTools = async function () { + const fresh = new LocalMCPServerTool('fresh', 'd', z.object({}).strict(), this); + (this as any).toolsCache = [fresh]; + (this as any).toolsDiscovered = true; + const ts = Date.now(); + (this as any).lastToolsUpdatedAt = ts; + (this as any).notifyToolsUpdated(ts); + return (this as any).listTools(); + }; + + await server.provision(); const tools = server.listTools(); - expect(tools.find((t) => String(t.name).endsWith('_fresh'))).toBeTruthy(); + expect(tools.map((t) => t.name)).toEqual(['x_fresh']); + expect(lastUpdate?.tools.map((tool: { name: string }) => tool.name)).toEqual(['x_fresh']); expect(typeof lastUpdate?.updatedAt).toBe('number'); }); }); diff --git a/packages/platform-server/__tests__/mcp.provision.dynamic.test.ts b/packages/platform-server/__tests__/mcp.provision.dynamic.test.ts index da0dd3ea8..c4f7d9b66 100644 --- a/packages/platform-server/__tests__/mcp.provision.dynamic.test.ts +++ b/packages/platform-server/__tests__/mcp.provision.dynamic.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { z } from 'zod'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; +import { LocalMCPServerTool } from '../src/nodes/mcp/localMcpServer.tool'; import type { McpServerConfig, McpTool } from '../src/mcp/types'; -import { createModuleRefStub } from './helpers/module-ref.stub'; import { WorkspaceProviderStub, WorkspaceNodeStub } from './helpers/workspace-provider.stub'; class MockLogger { @@ -9,7 +10,7 @@ class MockLogger { debug = vi.fn(); error = vi.fn(); } -describe('LocalMCPServer provision/deprovision + enabledTools filtering', () => { +describe('LocalMCPServer provision/deprovision + toolFilter', () => { let server: LocalMCPServerNode; let logger: MockLogger; @@ -19,7 +20,7 @@ describe('LocalMCPServer provision/deprovision + enabledTools filtering', () => resolveEnvItems: vi.fn(async () => ({})), resolveProviderEnv: vi.fn(async () => ({})), } as any; - server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + server = new LocalMCPServerNode(envStub, {} as any); (server as any).logger = logger; const provider = new WorkspaceProviderStub(); const workspaceNode = new WorkspaceNodeStub(provider); @@ -64,28 +65,17 @@ describe('LocalMCPServer provision/deprovision + enabledTools filtering', () => expect(server.status).toBe('not_ready'); }); - // Dynamic-config APIs removed; use setState(enabledTools) + listTools filtering. - - it('setState(enabledTools) filters listTools output by raw names or namespaced', async () => { - await server.setConfig({ namespace: 'ns', command: 'cmd' } as McpServerConfig); - // Preload cache with discovered tools (creates LocalMCPServerTool instances) - server.preloadCachedTools([ { name: 'toolA' } as any, { name: 'toolB' } as any ]); - let tools = server.listTools(); - expect(tools).toEqual([]); - - // Enable only toolA using raw name - await server.setState({ mcp: { enabledTools: ['toolA'] } }); - tools = server.listTools(); - expect(tools.map(t => t.name)).toEqual(['ns_toolA']); - - // Enable only toolB using namespaced form - await server.setState({ mcp: { enabledTools: ['ns_toolB'] } }); - tools = server.listTools(); - expect(tools.map(t => t.name)).toEqual(['ns_toolB']); - - // Empty array disables all - await server.setState({ mcp: { enabledTools: [] } }); - tools = server.listTools(); - expect(tools.length).toBe(0); + it('applies toolFilter rules when listing tools', async () => { + await server.setConfig({ + namespace: 'ns', + command: 'cmd', + toolFilter: { mode: 'allow', rules: [{ pattern: 'toolA' }] }, + } as McpServerConfig); + (server as any).toolsCache = [ + new LocalMCPServerTool('toolA', 'A', z.object({}).strict(), server), + new LocalMCPServerTool('toolB', 'B', z.object({}).strict(), server), + ]; + const tools = server.listTools(); + expect(tools.map((t) => t.name)).toEqual(['ns_toolA']); }); }); diff --git a/packages/platform-server/__tests__/mcp.tools.sync.test.ts b/packages/platform-server/__tests__/mcp.tools.sync.test.ts index daa127aed..c696de22d 100644 --- a/packages/platform-server/__tests__/mcp.tools.sync.test.ts +++ b/packages/platform-server/__tests__/mcp.tools.sync.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { z } from 'zod'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; -import { createModuleRefStub } from './helpers/module-ref.stub'; +import { LocalMCPServerTool } from '../src/nodes/mcp/localMcpServer.tool'; class MockLogger { info = vi.fn(); @@ -8,61 +9,52 @@ class MockLogger { error = vi.fn(); } -describe('LocalMCPServerNode listTools filtering by enabledTools', () => { +describe('LocalMCPServerNode tool filtering', () => { let server: LocalMCPServerNode; beforeEach(async () => { - const nodeStateService = { getSnapshot: vi.fn((_id: string) => ({ mcp: { enabledTools: [] } })) } as any; - const moduleRef = createModuleRefStub({ get: vi.fn(() => nodeStateService) }); const envStub = { resolveEnvItems: vi.fn(), resolveProviderEnv: vi.fn() } as any; - const logger = new MockLogger(); - server = new LocalMCPServerNode(envStub, {} as any, moduleRef as any); - (server as any).logger = logger; - // Manually init nodeId since we are not running through runtime + server = new LocalMCPServerNode(envStub, {} as any); + (server as any).logger = new MockLogger(); (server as any).init({ nodeId: 'node-1' }); await server.setConfig({ namespace: 'ns' } as any); - // Preload two tools into cache - (server as any).preloadCachedTools([ - { name: 'a', description: 'A', inputSchema: { type: 'object' } }, - { name: 'b', description: 'B', inputSchema: { type: 'object' } }, - ], Date.now()); - }); - - it('returns [] when enabledTools=[]', () => { - // enabledTools is [] from beforeEach - const tools = server.listTools(); - expect(tools.length).toBe(0); + (server as any).toolsCache = [ + new LocalMCPServerTool('alpha', 'A', z.object({}).strict(), server), + new LocalMCPServerTool('beta', 'B', z.object({}).strict(), server), + ]; }); - it('returns [] when enabledTools is undefined', () => { - const ns = { getSnapshot: vi.fn(() => ({ mcp: {} })) } as any; - (server as any).nodeStateService = ns; - // Clear last enabled tools cache as well - (server as any)._lastEnabledTools = undefined; + it('returns all tools when no filter is set', () => { const tools = server.listTools(); - expect(tools).toEqual([]); + expect(tools.map((t) => t.name)).toEqual(['ns_alpha', 'ns_beta']); }); - it('returns only enabled tool when enabledTools=["ns_a"]', () => { - // Update snapshot to include only ns_a - const ns = { getSnapshot: vi.fn((_id: string) => ({ mcp: { enabledTools: ['ns_a'] } })) } as any; - (server as any).nodeStateService = ns; + it('filters tools using allow rules', async () => { + await server.setConfig({ + namespace: 'ns', + toolFilter: { mode: 'allow', rules: [{ pattern: 'beta' }] }, + } as any); const tools = server.listTools(); - expect(tools.map(t => t.name)).toEqual(['ns_a']); + expect(tools.map((t) => t.name)).toEqual(['ns_beta']); }); }); -describe('LocalMCPServerNode setState enabledTools emits mcp.tools_updated', () => { - it('emits on hook invocation', async () => { +describe('LocalMCPServerNode config updates emit tools_updated', () => { + it('emits on setConfig changes', async () => { const logger = new MockLogger(); const envStub = { resolveEnvItems: vi.fn(), resolveProviderEnv: vi.fn() } as any; - const server = new LocalMCPServerNode(envStub, {} as any, createModuleRefStub()); + const server = new LocalMCPServerNode(envStub, {} as any); (server as any).logger = logger; - // Preload one tool for payload consistency - (server as any).preloadCachedTools([{ name: 'x', description: 'X', inputSchema: { type: 'object' } }], Date.now()); - let fired = false; - (server as any).on('mcp.tools_updated', (_payload: { tools: unknown[]; updatedAt: number }) => { fired = true; }); - await server.setState({ mcp: { tools: [], toolsUpdatedAt: Date.now(), enabledTools: ['ns_x'] } as any }); - expect(fired).toBe(true); + (server as any).toolsCache = [new LocalMCPServerTool('x', 'X', z.object({}).strict(), server)]; + + let fired: { tools: unknown[]; updatedAt: number } | null = null; + server.on('mcp.tools_updated', (payload: { tools: unknown[]; updatedAt: number }) => { + fired = payload; + }); + + await server.setConfig({ namespace: 'ns' } as any); + expect(fired).toBeTruthy(); + expect(Array.isArray(fired?.tools)).toBe(true); + expect(typeof fired?.updatedAt).toBe('number'); }); }); diff --git a/packages/platform-server/__tests__/memory.runtime.integration.test.ts b/packages/platform-server/__tests__/memory.runtime.integration.test.ts index d8e7e343e..9c0266b41 100644 --- a/packages/platform-server/__tests__/memory.runtime.integration.test.ts +++ b/packages/platform-server/__tests__/memory.runtime.integration.test.ts @@ -120,7 +120,6 @@ function makeRuntime( async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} } // Cast moduleRef back to real ModuleRef type for LiveGraphRuntime ctor compatibility const resolver = { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) }; diff --git a/packages/platform-server/__tests__/mixed.shell.mcp.isolation.test.ts b/packages/platform-server/__tests__/mixed.shell.mcp.isolation.test.ts index f70d923e9..8abd235f6 100644 --- a/packages/platform-server/__tests__/mixed.shell.mcp.isolation.test.ts +++ b/packages/platform-server/__tests__/mixed.shell.mcp.isolation.test.ts @@ -4,7 +4,6 @@ import { EnvService } from '../src/env/env.service'; import { ShellCommandNode } from '../src/nodes/tools/shell_command/shell_command.node'; import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; import { Signal } from '../src/signal'; -import { createModuleRefStub } from './helpers/module-ref.stub'; import { WorkspaceProviderStub, WorkspaceNodeStub } from './helpers/workspace-provider.stub'; import { runnerConfigDefaults } from './helpers/config'; @@ -55,7 +54,7 @@ describe('Mixed Shell + MCP overlay isolation', () => { shell.setContainerProvider(workspaceNode as unknown as ShellCommandNode['provider']); await shell.setConfig({ env: [ { name: 'S_VAR', value: 's' } ] }); - const mcp = new LocalMCPServerNode(envService as any, cfg as any, createModuleRefStub()); + const mcp = new LocalMCPServerNode(envService as any, cfg as any); mcp.init({ nodeId: 'mcp' }); (mcp as any).setContainerProvider(workspaceNode); await mcp.setConfig({ namespace: 'n', command: 'mcp start --stdio', env: [ { name: 'M_VAR', value: 'm' } ], startupTimeoutMs: 10 }); diff --git a/packages/platform-server/__tests__/nodeState.service.merge.test.ts b/packages/platform-server/__tests__/nodeState.service.merge.test.ts deleted file mode 100644 index 01ceba7f1..000000000 --- a/packages/platform-server/__tests__/nodeState.service.merge.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { deepMergeNodeState } from '../src/graph/nodeState.service'; - -describe('deepMergeNodeState', () => { - it('retains existing mcp.tools/toolsUpdatedAt and adds enabledTools', () => { - const prev = { mcp: { tools: [{ name: 'a' }], toolsUpdatedAt: 1 } } as Record; - const patch = { mcp: { enabledTools: ['a'] } } as Record; - const merged = deepMergeNodeState(prev, patch); - expect(merged).toEqual({ mcp: { tools: [{ name: 'a' }], toolsUpdatedAt: 1, enabledTools: ['a'] } }); - }); - - it('retains existing mcp.enabledTools and adds tools/toolsUpdatedAt', () => { - const prev = { mcp: { enabledTools: ['a'] } } as Record; - const patch = { mcp: { tools: [{ name: 'a' }], toolsUpdatedAt: 1 } } as Record; - const merged = deepMergeNodeState(prev, patch); - expect(merged).toEqual({ mcp: { enabledTools: ['a'], tools: [{ name: 'a' }], toolsUpdatedAt: 1 } }); - }); -}); diff --git a/packages/platform-server/__tests__/routes.node.actions.test.ts b/packages/platform-server/__tests__/routes.node.actions.test.ts index d0889af5a..b0dd9a5a7 100644 --- a/packages/platform-server/__tests__/routes.node.actions.test.ts +++ b/packages/platform-server/__tests__/routes.node.actions.test.ts @@ -1,37 +1,54 @@ import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; import { GraphController } from '../src/graph/controllers/graph.controller'; import { HttpException, HttpStatus } from '@nestjs/common'; +import { LocalMCPServerNode } from '../src/nodes/mcp/localMcpServer.node'; +import { LocalMCPServerTool } from '../src/nodes/mcp/localMcpServer.tool'; -describe('POST /api/graph/nodes/:id/actions', () => { - function makeController() { - type TemplateRegistryStub = { toSchema: () => unknown[] }; - type RuntimeStub = { provisionNode: (id: string) => Promise; deprovisionNode: (id: string) => Promise }; - type LoggerStub = { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; - type NodeStateStub = { upsertNodeState: (nodeId: string, state: Record) => Promise }; - const templateRegistry: TemplateRegistryStub = { toSchema: vi.fn(() => []) }; - const runtime: RuntimeStub = { - provisionNode: vi.fn(async (_id: string) => {}), - deprovisionNode: vi.fn(async (_id: string) => {}), - }; - const logger: LoggerStub = { info: vi.fn(), error: vi.fn() }; - const nodeState: NodeStateStub = { upsertNodeState: vi.fn(async (_id, _state) => {}) }; - // Pass typed stubs; GraphController only uses methods defined above in this test scope - // Cast to never to satisfy constructor types without using `any` or double unknown casts - return new GraphController(templateRegistry as never, runtime as never, logger as never, nodeState as never); - } +type TemplateRegistryStub = { toSchema: () => unknown[] }; +type GraphRepositoryStub = { + initIfNeeded: () => Promise; + get: () => Promise; + upsert: () => Promise; +}; +type RuntimeStub = { + provisionNode: (id: string) => Promise; + deprovisionNode: (id: string) => Promise; + getNodeInstance: (id: string) => unknown; +}; + +function makeController(runtimeOverrides: Partial = {}) { + const templateRegistry: TemplateRegistryStub = { toSchema: vi.fn(() => []) }; + const runtime: RuntimeStub = { + provisionNode: vi.fn(async (_id: string) => {}), + deprovisionNode: vi.fn(async (_id: string) => {}), + getNodeInstance: vi.fn(), + ...runtimeOverrides, + }; + const graphRepository: GraphRepositoryStub = { + initIfNeeded: vi.fn(async () => {}), + get: vi.fn(async () => null), + upsert: vi.fn(async () => ({ name: 'main', version: 1, updatedAt: new Date().toISOString(), nodes: [], edges: [] })), + }; + return { + controller: new GraphController(templateRegistry as never, runtime as never, graphRepository as never), + runtime, + }; +} +describe('POST /api/graph/nodes/:id/actions', () => { it('returns 204 (null body) for provision and deprovision', async () => { - const ctrl = makeController(); - const res1 = await ctrl.postNodeAction('n1', { action: 'provision' }); + const { controller } = makeController(); + const res1 = await controller.postNodeAction('n1', { action: 'provision' }); expect(res1).toBeNull(); - const res2 = await ctrl.postNodeAction('n1', { action: 'deprovision' }); + const res2 = await controller.postNodeAction('n1', { action: 'deprovision' }); expect(res2).toBeNull(); }); it('returns 400 for invalid action payload', async () => { - const ctrl = makeController(); + const { controller } = makeController(); try { - await ctrl.postNodeAction('n1', { action: 'invalid' }); + await controller.postNodeAction('n1', { action: 'invalid' }); // Should not reach expect(false).toBe(true); } catch (e) { @@ -44,3 +61,49 @@ describe('POST /api/graph/nodes/:id/actions', () => { } }); }); + +describe('POST /api/graph/nodes/:id/discover-tools', () => { + it('returns tool snapshot for MCP nodes', async () => { + const envStub = { resolveEnvItems: vi.fn(), resolveProviderEnv: vi.fn() } as any; + const node = new LocalMCPServerNode(envStub, {} as any); + node.init({ nodeId: 'node-1' }); + await node.setConfig({ namespace: 'ns' } as any); + (node as any).toolsCache = [new LocalMCPServerTool('alpha', 'A', z.object({}).strict(), node)]; + (node as any).lastToolsUpdatedAt = 1700000000000; + const discoverSpy = vi.spyOn(node, 'discoverTools').mockResolvedValue((node as any).toolsCache); + + const { controller } = makeController({ getNodeInstance: vi.fn(() => node) }); + const res = await controller.discoverTools('node-1'); + expect(discoverSpy).toHaveBeenCalled(); + expect(res.tools).toEqual([{ name: 'ns_alpha', description: 'A' }]); + expect(res.updatedAt).toBe(new Date(1700000000000).toISOString()); + }); + + it('returns 404 when node is missing', async () => { + const { controller } = makeController({ getNodeInstance: vi.fn(() => undefined) }); + try { + await controller.discoverTools('missing'); + expect(false).toBe(true); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.NOT_FOUND); + } else { + expect(false).toBe(true); + } + } + }); + + it('returns 400 when node is not MCP', async () => { + const { controller } = makeController({ getNodeInstance: vi.fn(() => ({}) as any) }); + try { + await controller.discoverTools('n1'); + expect(false).toBe(true); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.BAD_REQUEST); + } else { + expect(false).toBe(true); + } + } + }); +}); diff --git a/packages/platform-server/__tests__/routes.variables.test.ts b/packages/platform-server/__tests__/routes.variables.test.ts deleted file mode 100644 index a940442cc..000000000 --- a/packages/platform-server/__tests__/routes.variables.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import Fastify from 'fastify'; -import { GraphVariablesController } from '../src/graph/controllers/graphVariables.controller'; -import { GraphVariablesService } from '../src/graph/services/graphVariables.service'; - -class InMemoryPrismaClient { - graphVariable = { - data: new Map(), - async findMany() { return Array.from(this.data.values()); }, - async findUnique(args: { where: { key: string } }) { return this.data.get(args.where.key) ?? null; }, - async create(args: { data: { key: string; value: string } }) { - const { key, value } = args.data; - this.data.set(key, { key, value }); - return { key, value }; - }, - async update(args: { where: { key: string }; data: { value: string } }) { - const key = args.where.key; - if (!this.data.has(key)) throw new Error('not_found'); - const value = args.data.value; - this.data.set(key, { key, value }); - return { key, value }; - }, - async deleteMany(args: { where: { key: string } }) { - const existed = this.data.delete(args.where.key); - return { count: existed ? 1 : 0 }; - }, - }; - variableLocal = { - data: new Map(), - async findMany() { return Array.from(this.data.values()); }, - async findUnique(args: { where: { key: string } }) { return this.data.get(args.where.key) ?? null; }, - async upsert(args: { where: { key: string }; update: { value: string }; create: { key: string; value: string } }) { - const key = args.where.key; - const existing = this.data.get(key); - if (existing) { this.data.set(key, { key, value: args.update.value }); return { key, value: args.update.value }; } - this.data.set(key, { key, value: args.create.value }); - return { key, value: args.create.value }; - }, - async delete(args: { where: { key: string } }) { this.data.delete(args.where.key); return {}; }, - async deleteMany(args: { where: { key: string } }) { const existed = this.data.delete(args.where.key); return { count: existed ? 1 : 0 }; }, - }; -} - -class PrismaStub { client = new InMemoryPrismaClient(); getClient() { return this.client as unknown as any; } } - -describe('GraphVariablesController routes', () => { - let fastify: any; let prismaSvc: PrismaStub; let controller: GraphVariablesController; - beforeEach(async () => { - fastify = Fastify({ logger: false }); prismaSvc = new PrismaStub(); - prismaSvc.client.graphVariable.data.set('A', { key: 'A', value: 'GA' }); - prismaSvc.client.graphVariable.data.set('B', { key: 'B', value: 'GB' }); - prismaSvc.client.variableLocal.data.set('B', { key: 'B', value: 'LB' }); prismaSvc.client.variableLocal.data.set('C', { key: 'C', value: 'LC' }); - const service = new GraphVariablesService(prismaSvc as any); - controller = new GraphVariablesController(service); - fastify.get('/api/graph/variables', async (_req, res) => res.send(await controller.list())); - // POST should return 201 like Nest's @HttpCode(201) - fastify.post('/api/graph/variables', async (req, res) => { - try { - const body = await controller.create(req.body); - return res.status(201).send(body); - } catch (e) { - const status = (e as any)?.status || 400; - return res.status(status).send({ error: (e as any)?.response?.error || 'error' }); - } - }); - fastify.put('/api/graph/variables/:key', async (req, res) => { const p = req.params as any; const b = req.body as any; try { const body = await controller.update(p.key, b); return res.send(body); } catch (e) { const status = (e as any)?.status || 400; return res.status(status).send({ error: (e as any)?.response?.error || 'error' }); } }); - fastify.delete('/api/graph/variables/:key', async (req, res) => { const p = req.params as any; try { await controller.remove(p.key); return res.status(204).send(); } catch (e) { const status = (e as any)?.status || 400; return res.status(status).send({ error: (e as any)?.response?.error || 'error' }); } }); - }); - - it('aggregates graph and local overrides', async () => { - const res = await fastify.inject({ method: 'GET', url: '/api/graph/variables' }); expect(res.statusCode).toBe(200); - const items = res.json().items as Array<{ key: string; graph: string | null; local: string | null }>; - const byKey = Object.fromEntries(items.map((i) => [i.key, i])); - expect(byKey['A'].graph).toBe('GA'); expect(byKey['A'].local).toBe(null); - expect(byKey['B'].graph).toBe('GB'); expect(byKey['B'].local).toBe('LB'); - expect(byKey['C'].graph).toBe(null); expect(byKey['C'].local).toBe('LC'); - }); - - it('creates new variable and enforces unique key', async () => { - const res = await fastify.inject({ method: 'POST', url: '/api/graph/variables', payload: { key: 'D', graph: 'GD' } }); expect(res.statusCode).toBe(201); - const resDup = await fastify.inject({ method: 'POST', url: '/api/graph/variables', payload: { key: 'A', graph: 'X' } }); expect(resDup.statusCode).toBe(409); - }); - - it('updates graph and local values; deletes local on empty', async () => { - const res = await fastify.inject({ method: 'PUT', url: '/api/graph/variables/B', payload: { graph: 'GB2' } }); expect(res.statusCode).toBe(200); - const res2 = await fastify.inject({ method: 'PUT', url: '/api/graph/variables/A', payload: { local: 'LA' } }); expect(res2.statusCode).toBe(200); - const res3 = await fastify.inject({ method: 'PUT', url: '/api/graph/variables/B', payload: { local: '' } }); expect(res3.statusCode).toBe(200); - const resList = await fastify.inject({ method: 'GET', url: '/api/graph/variables' }); const items = resList.json().items as Array<{ key: string; graph: string | null; local: string | null }>; - const byKey = Object.fromEntries(items.map((i) => [i.key, i])); expect(byKey['B'].graph).toBe('GB2'); expect(byKey['B'].local).toBe(null); expect(byKey['A'].local).toBe('LA'); - }); - - it('rejects invalid graph value on PUT', async () => { - const res = await fastify.inject({ method: 'PUT', url: '/api/graph/variables/A', payload: { graph: '' } }); - expect(res.statusCode).toBe(400); - expect(res.json().error).toBe('BAD_VALUE'); - }); - - it('deletes variable from graph and local override', async () => { - await fastify.inject({ method: 'PUT', url: '/api/graph/variables/C', payload: { local: 'LC2' } }); - const resDel = await fastify.inject({ method: 'DELETE', url: '/api/graph/variables/C' }); expect(resDel.statusCode).toBe(204); - const resList = await fastify.inject({ method: 'GET', url: '/api/graph/variables' }); - const items = resList.json().items as Array<{ key: string }>[]; expect(items.find((i: any) => i.key === 'C')).toBeUndefined(); - }); -}); diff --git a/packages/platform-server/__tests__/runtime.api.helpers.test.ts b/packages/platform-server/__tests__/runtime.api.helpers.test.ts index 0debff3d9..0f388406f 100644 --- a/packages/platform-server/__tests__/runtime.api.helpers.test.ts +++ b/packages/platform-server/__tests__/runtime.api.helpers.test.ts @@ -15,7 +15,6 @@ class StubRepo extends GraphRepository { async initIfNeeded(): Promise {} async get(): Promise { return null; } async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} } function makeRuntimeAndRegistry() { const moduleRef = new ModuleRefStub() as ModuleRef; diff --git a/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts b/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts index bf8eb0d91..a8a3fd1cb 100644 --- a/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts +++ b/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts @@ -48,7 +48,6 @@ const makeRuntime = ( async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} } const resolver = { resolve: async (input: unknown) => @@ -84,25 +83,6 @@ describe('runtime config unknown keys handling', () => { expect(Object.keys((ports as any).sourcePorts || {})).toContain('$self'); }); - // dynamicConfig fully removed; replace test to assert state persistence path - it('node state is persisted via updateNodeState', async () => { - const runtime = makeRuntime(); - const g: GraphDefinition = { - nodes: [ - { - id: 'n2', - data: { template: 'Memory', config: { scope: 'global' }, state: { info: 'x' } }, - }, - ], - edges: [], - }; - const res = await runtime.apply(g); - expect(res.errors.length).toBe(0); - // state is available in lastGraph snapshot - const nodes = runtime.getNodes(); - expect(nodes.find((n) => n.id === 'n2')).toBeTruthy(); - }); - it('updates live config on config update path', async () => { const runtime = makeRuntime(); const g1: GraphDefinition = { diff --git a/packages/platform-server/__tests__/socket.events.test.ts b/packages/platform-server/__tests__/socket.events.test.ts index 6a2a597d8..084f41288 100644 --- a/packages/platform-server/__tests__/socket.events.test.ts +++ b/packages/platform-server/__tests__/socket.events.test.ts @@ -23,7 +23,6 @@ describe('Socket events', () => { subscribeToToolOutputChunk: () => () => {}, subscribeToToolOutputTerminal: () => () => {}, subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, subscribeToThreadCreated: () => () => {}, subscribeToThreadUpdated: () => () => {}, subscribeToMessageCreated: () => () => {}, @@ -62,40 +61,6 @@ describe('Socket events', () => { expect(payload).toMatchObject({ nodeId: 'n1', provisionStatus: { state: 'provisioning' } }); }); - it('emits node_state via NodeStateService bridge', async () => { - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as unknown as PrismaService; - const metrics = new ThreadsMetricsService(prismaStub as any); - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metrics, prismaStub, eventsBusStub as any); - gateway.init({ server: fastify.server }); - const emitMap = new Map>(); - const toSpy = vi.fn((room: string) => { - if (!emitMap.has(room)) emitMap.set(room, vi.fn()); - return { emit: emitMap.get(room)! }; - }); - (gateway as any).io = { to: toSpy }; - gateway.emitNodeState('n1', { k: 'v' }); - expect(toSpy).toHaveBeenCalledWith('graph'); - expect(toSpy).toHaveBeenCalledWith('node:n1'); - expect(emitMap.get('graph')).toHaveBeenCalledWith('node_state', expect.objectContaining({ nodeId: 'n1', state: { k: 'v' } })); - expect(emitMap.get('node:n1')).toHaveBeenCalledWith('node_state', expect.objectContaining({ nodeId: 'n1', state: { k: 'v' } })); - }); - it('emits reminder count to graph and node rooms', async () => { const adapter = new FastifyAdapter(); const fastify = adapter.getInstance(); @@ -107,7 +72,6 @@ describe('Socket events', () => { subscribeToToolOutputChunk: () => () => {}, subscribeToToolOutputTerminal: () => () => {}, subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, subscribeToThreadCreated: () => () => {}, subscribeToThreadUpdated: () => () => {}, subscribeToMessageCreated: () => () => {}, diff --git a/packages/platform-server/__tests__/socket.gateway.test.ts b/packages/platform-server/__tests__/socket.gateway.test.ts index cf1efbb9f..2b690dbd2 100644 --- a/packages/platform-server/__tests__/socket.gateway.test.ts +++ b/packages/platform-server/__tests__/socket.gateway.test.ts @@ -16,7 +16,6 @@ describe('GraphSocketGateway', () => { subscribeToToolOutputChunk: () => () => {}, subscribeToToolOutputTerminal: () => () => {}, subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, subscribeToThreadCreated: () => () => {}, subscribeToThreadUpdated: () => () => {}, subscribeToMessageCreated: () => () => {}, diff --git a/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts b/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts index ed6c42b33..5bfe9bf31 100644 --- a/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts +++ b/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts @@ -19,7 +19,6 @@ describe('GraphSocketGateway metrics coalescing', () => { subscribeToToolOutputChunk: () => () => {}, subscribeToToolOutputTerminal: () => () => {}, subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, subscribeToThreadCreated: () => () => {}, subscribeToThreadUpdated: () => () => {}, subscribeToMessageCreated: () => () => {}, diff --git a/packages/platform-server/__tests__/socket.node_status.integration.test.ts b/packages/platform-server/__tests__/socket.node_status.integration.test.ts index aa00be962..8d2f0efba 100644 --- a/packages/platform-server/__tests__/socket.node_status.integration.test.ts +++ b/packages/platform-server/__tests__/socket.node_status.integration.test.ts @@ -21,7 +21,6 @@ describe('Gateway node_status integration', () => { subscribeToToolOutputChunk: () => () => {}, subscribeToToolOutputTerminal: () => () => {}, subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, subscribeToThreadCreated: () => () => {}, subscribeToThreadUpdated: () => () => {}, subscribeToMessageCreated: () => () => {}, diff --git a/packages/platform-server/__tests__/socket.realtime.integration.test.ts b/packages/platform-server/__tests__/socket.realtime.integration.test.ts index 34360ea80..35d5d3585 100644 --- a/packages/platform-server/__tests__/socket.realtime.integration.test.ts +++ b/packages/platform-server/__tests__/socket.realtime.integration.test.ts @@ -45,7 +45,6 @@ const createEventsBusNoop = (): EventsBusService => subscribeToToolOutputChunk: () => () => undefined, subscribeToToolOutputTerminal: () => () => undefined, subscribeToReminderCount: () => () => undefined, - subscribeToNodeState: () => () => undefined, subscribeToThreadCreated: () => () => undefined, subscribeToThreadUpdated: () => () => undefined, subscribeToMessageCreated: () => () => undefined, diff --git a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts index eae029420..f2b8e60a8 100644 --- a/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -4,7 +4,7 @@ import { HttpStatus } from '@nestjs/common'; import { ListAgentsRequestSchema, type ListAgentsRequest, - type PaginatedAgents, + type ListAgentsResponse, TeamsService, } from '../../src/proto/gen/agynio/api/teams/v1/teams_pb.js'; import { TeamsGrpcClient } from '../../src/teams/teamsGrpc.client'; @@ -89,7 +89,7 @@ describe('TeamsGrpcClient', () => { const listAgentsStub = vi.fn(async (_req: ListAgentsRequest, options?: CallOptions) => { captured.options = options; - return { items: [], page: 0, perPage: 0, total: 0n } as PaginatedAgents; + return { agents: [], nextPageToken: '' } as ListAgentsResponse; }); (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { @@ -108,7 +108,7 @@ describe('TeamsGrpcClient', () => { const listAgentsStub = vi.fn(async (_req: ListAgentsRequest, options?: CallOptions) => { captured.options = options; - return { items: [], page: 0, perPage: 0, total: 0n } as PaginatedAgents; + return { agents: [], nextPageToken: '' } as ListAgentsResponse; }); (client as unknown as { client: { listAgents: typeof listAgentsStub } }).client = { diff --git a/packages/platform-server/__tests__/teamsGraph.source.test.ts b/packages/platform-server/__tests__/teamsGraph.source.test.ts index 930938fcf..897e12002 100644 --- a/packages/platform-server/__tests__/teamsGraph.source.test.ts +++ b/packages/platform-server/__tests__/teamsGraph.source.test.ts @@ -23,30 +23,30 @@ import { describe('TeamsGraphSource', () => { it('maps Teams entities and attachments into graph nodes/edges', async () => { const agent = create(AgentSchema, { - id: 'agent-1', + meta: { id: 'agent-1' }, title: ' Agent One ', description: '', config: create(AgentConfigSchema, { name: ' Casey ', role: ' Lead ', model: 'gpt-4' }), }); const tool = create(ToolSchema, { - id: 'tool-shell', + meta: { id: 'tool-shell' }, name: ' Shell Tool ', description: 'ignored', type: ToolType.SHELL_COMMAND, config: { mode: 'fast' }, }); const mcp = create(McpServerSchema, { - id: 'mcp-1', + meta: { id: 'mcp-1' }, title: ' MCP Server ', config: { namespace: 'tools', command: 'run', workdir: '/srv', env: [{ name: 'TOKEN', value: 'secret' }] }, }); const detachedMcp = create(McpServerSchema, { - id: 'mcp-2', + meta: { id: 'mcp-2' }, title: ' Detached MCP ', config: { namespace: 'detached', command: 'run2' }, }); const workspace = create(WorkspaceConfigurationSchema, { - id: 'workspace-1', + meta: { id: 'workspace-1' }, title: ' Workspace ', config: create(WorkspaceConfigSchema, { image: 'ubuntu', @@ -55,7 +55,7 @@ describe('TeamsGraphSource', () => { }), }); const detachedWorkspace = create(WorkspaceConfigurationSchema, { - id: 'workspace-2', + meta: { id: 'workspace-2' }, title: ' Workspace Two ', config: create(WorkspaceConfigSchema, { image: 'debian', @@ -63,7 +63,7 @@ describe('TeamsGraphSource', () => { }), }); const memoryBucket = create(MemoryBucketSchema, { - id: 'memory-1', + meta: { id: 'memory-1' }, title: ' Memory ', config: create(MemoryBucketConfigSchema, { scope: MemoryBucketScope.GLOBAL, @@ -72,7 +72,7 @@ describe('TeamsGraphSource', () => { }); const attachments = [ create(AttachmentSchema, { - id: 'attach-agent-tool', + meta: { id: 'attach-agent-tool' }, kind: AttachmentKind.AGENT_TOOL, sourceId: 'agent-1', targetId: 'tool-shell', @@ -80,7 +80,7 @@ describe('TeamsGraphSource', () => { targetType: EntityType.TOOL, }), create(AttachmentSchema, { - id: 'attach-agent-mcp', + meta: { id: 'attach-agent-mcp' }, kind: AttachmentKind.AGENT_MCP_SERVER, sourceId: 'agent-1', targetId: 'mcp-1', @@ -88,7 +88,7 @@ describe('TeamsGraphSource', () => { targetType: EntityType.MCP_SERVER, }), create(AttachmentSchema, { - id: 'attach-agent-memory', + meta: { id: 'attach-agent-memory' }, kind: AttachmentKind.AGENT_MEMORY_BUCKET, sourceId: 'agent-1', targetId: 'memory-1', @@ -96,7 +96,7 @@ describe('TeamsGraphSource', () => { targetType: EntityType.MEMORY_BUCKET, }), create(AttachmentSchema, { - id: 'attach-agent-workspace', + meta: { id: 'attach-agent-workspace' }, kind: AttachmentKind.AGENT_WORKSPACE_CONFIGURATION, sourceId: 'agent-1', targetId: 'workspace-1', @@ -104,7 +104,7 @@ describe('TeamsGraphSource', () => { targetType: EntityType.WORKSPACE_CONFIGURATION, }), create(AttachmentSchema, { - id: 'attach-mcp-workspace', + meta: { id: 'attach-mcp-workspace' }, kind: AttachmentKind.MCP_SERVER_WORKSPACE_CONFIGURATION, sourceId: 'mcp-1', targetId: 'workspace-1', @@ -112,7 +112,7 @@ describe('TeamsGraphSource', () => { targetType: EntityType.WORKSPACE_CONFIGURATION, }), create(AttachmentSchema, { - id: 'attach-mcp-workspace-detached', + meta: { id: 'attach-mcp-workspace-detached' }, kind: AttachmentKind.MCP_SERVER_WORKSPACE_CONFIGURATION, sourceId: 'mcp-2', targetId: 'workspace-2', diff --git a/packages/platform-server/prisma/schema.prisma b/packages/platform-server/prisma/schema.prisma index 0266babc8..814f8c385 100644 --- a/packages/platform-server/prisma/schema.prisma +++ b/packages/platform-server/prisma/schema.prisma @@ -21,33 +21,6 @@ model ConversationState { @@unique([threadId, nodeId]) } -// Local variable overrides (single-user/single-graph model) -model VariableLocal { - id Int @id @default(autoincrement()) - key String @unique - value String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Graph-level variables persisted outside the Teams graph source -model GraphVariable { - id Int @id @default(autoincrement()) - key String @unique - value String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Persisted runtime state for graph nodes (MCP enabled tools, etc.) -model GraphNodeState { - id Int @id @default(autoincrement()) - nodeId String @unique - state Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - model LiteLLMVirtualKey { id Int @id @default(autoincrement()) alias String @unique diff --git a/packages/platform-server/src/agents/agents.persistence.service.ts b/packages/platform-server/src/agents/agents.persistence.service.ts index ddf1e7d86..a01fa92d3 100644 --- a/packages/platform-server/src/agents/agents.persistence.service.ts +++ b/packages/platform-server/src/agents/agents.persistence.service.ts @@ -1278,7 +1278,7 @@ export class AgentsPersistenceService { const agentById = new Map(); for (const agent of agents) { - const id = readString(agent.id); + const id = readString(agent.meta?.id); if (!id) continue; agentById.set(id, agent); } @@ -1295,8 +1295,10 @@ export class AgentsPersistenceService { } private async listAllTeamsAgents(): Promise { - return listAllPages((page, perPage) => - this.teamsClient.listAgents(create(ListAgentsRequestSchema, { page, perPage })), + return listAllPages((pageToken, pageSize) => + this.teamsClient + .listAgents(create(ListAgentsRequestSchema, { pageSize, pageToken: pageToken ?? '' })) + .then((response) => ({ items: response.agents, nextPageToken: response.nextPageToken })), ); } diff --git a/packages/platform-server/src/env/env.module.ts b/packages/platform-server/src/env/env.module.ts index ab40141ff..19b00ee5c 100644 --- a/packages/platform-server/src/env/env.module.ts +++ b/packages/platform-server/src/env/env.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { VaultModule } from '../vault/vault.module'; import { EnvService } from './env.service'; import { ReferenceResolverService } from '../utils/reference-resolver.service'; +import { TeamsModule } from '../teams/teams.module'; @Module({ - imports: [VaultModule], + imports: [VaultModule, TeamsModule], providers: [EnvService, ReferenceResolverService], exports: [EnvService, ReferenceResolverService], }) diff --git a/packages/platform-server/src/events/events-bus.service.ts b/packages/platform-server/src/events/events-bus.service.ts index 30c6f6fc9..124685750 100644 --- a/packages/platform-server/src/events/events-bus.service.ts +++ b/packages/platform-server/src/events/events-bus.service.ts @@ -25,12 +25,6 @@ export type ReminderCountEvent = { threadId?: string; }; -export type NodeStateBusEvent = { - nodeId: string; - state: Record; - updatedAtMs?: number; -}; - export type ThreadBroadcast = { id: string; alias: string; @@ -74,7 +68,6 @@ type EventsBusEvents = { tool_output_chunk: [ToolOutputChunkPayload]; tool_output_terminal: [ToolOutputTerminalPayload]; reminder_count: [ReminderCountEvent]; - node_state: [NodeStateBusEvent]; thread_created: [ThreadBroadcast]; thread_updated: [ThreadBroadcast]; message_created: [{ threadId: string; message: MessageBroadcast }]; @@ -141,17 +134,6 @@ export class EventsBusService implements OnModuleDestroy { this.emitter.emit('reminder_count', payload); } - subscribeToNodeState(listener: (payload: NodeStateBusEvent) => void): () => void { - this.emitter.on('node_state', listener); - return () => { - this.emitter.off('node_state', listener); - }; - } - - emitNodeState(payload: NodeStateBusEvent): void { - this.emitter.emit('node_state', payload); - } - subscribeToThreadCreated(listener: (thread: ThreadBroadcast) => void): () => void { this.emitter.on('thread_created', listener); return () => { diff --git a/packages/platform-server/src/gateway/graph.socket.gateway.ts b/packages/platform-server/src/gateway/graph.socket.gateway.ts index 8eb04c8b8..72e2f80df 100644 --- a/packages/platform-server/src/gateway/graph.socket.gateway.ts +++ b/packages/platform-server/src/gateway/graph.socket.gateway.ts @@ -7,7 +7,6 @@ import type { ThreadStatus, MessageKind, RunStatus } from '@prisma/client'; import { EventsBusService, type MessageBroadcast, - type NodeStateBusEvent, type ReminderCountEvent as ReminderCountBusEvent, type RunEventBroadcast, type RunEventBusPayload, @@ -42,15 +41,6 @@ export const NodeStatusEventSchema = z .strict(); export type NodeStatusEvent = z.infer; -export const NodeStateEventSchema = z - .object({ - nodeId: z.string(), - state: z.record(z.string(), z.unknown()), - updatedAt: z.string().datetime(), - }) - .strict(); -export type NodeStateEvent = z.infer; - // RemindMe: active reminder count event export const ReminderCountSocketEventSchema = z .object({ @@ -123,7 +113,6 @@ export class GraphSocketGateway implements OnModuleInit, OnModuleDestroy { this.cleanup.push(this.eventsBus.subscribeToToolOutputChunk(this.handleToolOutputChunk)); this.cleanup.push(this.eventsBus.subscribeToToolOutputTerminal(this.handleToolOutputTerminal)); this.cleanup.push(this.eventsBus.subscribeToReminderCount(this.handleReminderCount)); - this.cleanup.push(this.eventsBus.subscribeToNodeState(this.handleNodeState)); this.cleanup.push(this.eventsBus.subscribeToThreadCreated(this.handleThreadCreated)); this.cleanup.push(this.eventsBus.subscribeToThreadUpdated(this.handleThreadUpdated)); this.cleanup.push(this.eventsBus.subscribeToMessageCreated(this.handleMessageCreated)); @@ -345,19 +334,6 @@ export class GraphSocketGateway implements OnModuleInit, OnModuleDestroy { }); }; - private readonly handleNodeState = (payload: NodeStateBusEvent): void => { - try { - this.emitNodeState(payload.nodeId, payload.state, payload.updatedAtMs); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit node_state${this.formatContext({ - nodeId: payload.nodeId, - error: this.toSafeError(err), - })}`, - ); - } - }; - private readonly handleThreadCreated = (thread: ThreadBroadcast): void => { try { this.emitThreadCreated(thread); @@ -458,7 +434,7 @@ export class GraphSocketGateway implements OnModuleInit, OnModuleDestroy { }; private broadcast( - event: 'node_status' | 'node_state' | 'node_reminder_count', + event: 'node_status' | 'node_reminder_count', payload: T, schema: z.ZodType, ) { @@ -488,15 +464,6 @@ export class GraphSocketGateway implements OnModuleInit, OnModuleDestroy { // Note: node-level subscription handled via runtime.subscribe() - /** Emit node_state event when NodeStateService updates runtime snapshot. Public for DI bridge usage. */ - emitNodeState(nodeId: string, state: Record, updatedAtMs?: number): void { - const payload: NodeStateEvent = { - nodeId, - state, - updatedAt: new Date(updatedAtMs ?? Date.now()).toISOString(), - }; - this.broadcast('node_state', payload, NodeStateEventSchema); - } /** Emit node_reminder_count event for RemindMe tool nodes when registry changes. */ emitReminderCount(nodeId: string, count: number, updatedAtMs?: number): void { const payload: ReminderCountSocketEvent = { diff --git a/packages/platform-server/src/graph-core/liveGraph.manager.ts b/packages/platform-server/src/graph-core/liveGraph.manager.ts index 26cc783a8..a612c8304 100644 --- a/packages/platform-server/src/graph-core/liveGraph.manager.ts +++ b/packages/platform-server/src/graph-core/liveGraph.manager.ts @@ -98,7 +98,6 @@ export class LiveGraphRuntime { id: string; template: string; config?: Record; - state?: Record; }>; edges: Array<{ source: string; sourceHandle: string; target: string; targetHandle: string }>; version: number; @@ -106,7 +105,7 @@ export class LiveGraphRuntime { ({ nodes: saved.nodes.map((n) => ({ id: n.id, - data: { template: n.template, config: n.config, state: n.state }, + data: { template: n.template, config: n.config }, })), edges: saved.edges.map((e) => ({ source: e.source, @@ -163,15 +162,6 @@ export class LiveGraphRuntime { } } - // Update persisted runtime snapshot state for a node (typed helper) - updateNodeState(id: string, state: Record): void { - if (!this.state.lastGraph) return; - const node = this.state.lastGraph.nodes.find((n) => n.id === id); - if (node) { - node.data.state = state; - } - } - // Return the live node instance (if present). getNodeInstance(id: string): Node | undefined { return this.state.nodes.get(id)?.instance; @@ -195,12 +185,6 @@ export class LiveGraphRuntime { return st ? { provisionStatus: { state: st } } : {}; } - /** Return the last known persisted runtime state snapshot for a node. */ - getNodeStateSnapshot(id: string): Record | undefined { - const node = this.state.lastGraph?.nodes.find((n) => n.id === id); - return node?.data?.state as Record | undefined; - } - private async _applyGraphInternal(next: GraphDefinition): Promise { const prev = this.state.lastGraph ?? ({ nodes: [], edges: [] } as GraphDefinition); const diff = this.computeDiff(prev, next); @@ -260,7 +244,7 @@ export class LiveGraphRuntime { // non-fatal } } - // 2b. Dynamic config removed: use node state mutations in future. + // 2b. Dynamic config removed. // 3. Remove edges (reverse if needed) BEFORE removing nodes this.logger.debug('Remove edges'); @@ -280,9 +264,8 @@ export class LiveGraphRuntime { await this.disposeNode(nodeId).catch((err) => pushError(err as GraphError)); } - // Persist next graph snapshot early so dependent services (e.g., NodeStateService) - // can read initial state during first edge attachment and provisioning. - // This ensures boot-time agent↔MCP sync uses the persisted state. + // Persist next graph snapshot early so dependent services + // can read graph metadata during first edge attachment and provisioning. this.state.lastGraph = next; // 5. Add edges @@ -404,7 +387,6 @@ export class LiveGraphRuntime { ); live.config = cleanedConfig; } - await created.setState(node.data.state ?? {}); } catch (e) { // Factory creation or any init error should include nodeId if (e instanceof GraphError) throw e; // already enriched diff --git a/packages/platform-server/src/graph/controllers/graph.controller.ts b/packages/platform-server/src/graph/controllers/graph.controller.ts index 3f4be3896..d2e23a525 100644 --- a/packages/platform-server/src/graph/controllers/graph.controller.ts +++ b/packages/platform-server/src/graph/controllers/graph.controller.ts @@ -1,17 +1,18 @@ -import { Controller, Get, Post, Put, Param, Body, HttpCode, HttpException, HttpStatus, Inject } from '@nestjs/common'; +import { Controller, Get, Post, Param, Body, HttpCode, HttpException, HttpStatus, Inject } from '@nestjs/common'; import { z } from 'zod'; import { TemplateRegistry } from '../../graph-core/templateRegistry'; -import type { TemplateNodeSchema } from '../../shared/types/graph.types'; +import type { PersistedGraph, TemplateNodeSchema } from '../../shared/types/graph.types'; import { LiveGraphRuntime } from '../../graph-core/liveGraph.manager'; import type { NodeStatusState } from '../../nodes/base/Node'; -import { NodeStateService } from '../nodeState.service'; +import { GraphRepository } from '../graph.repository'; +import { LocalMCPServerNode } from '../../nodes/mcp/localMcpServer.node'; @Controller('api/graph') export class GraphController { constructor( @Inject(TemplateRegistry) private readonly templateRegistry: TemplateRegistry, @Inject(LiveGraphRuntime) private readonly runtime: LiveGraphRuntime, - @Inject(NodeStateService) private readonly nodeState: NodeStateService, + @Inject(GraphRepository) private readonly graphRepository: GraphRepository, ) {} @Get('templates') @@ -19,6 +20,20 @@ export class GraphController { return this.templateRegistry.toSchema(); } + @Get() + async getGraph(): Promise { + await this.graphRepository.initIfNeeded(); + const graph = await this.graphRepository.get('main'); + if (graph) return graph; + return { + name: 'main', + version: 0, + updatedAt: new Date().toISOString(), + nodes: [], + edges: [], + }; + } + @Get('nodes/:nodeId/status') async getNodeStatus( @Param('nodeId') nodeId: string, @@ -26,33 +41,35 @@ export class GraphController { return this.runtime.getNodeStatus(nodeId); } - // Node state endpoints (strict schemas) - @Get('nodes/:nodeId/state') - async getNodeState(@Param('nodeId') nodeId: string): Promise<{ state: Record }> { - const state = this.runtime.getNodeStateSnapshot(nodeId) || {}; - return { state }; - } - - @Put('nodes/:nodeId/state') - async putNodeState( + @Post('nodes/:nodeId/discover-tools') + async discoverTools( @Param('nodeId') nodeId: string, - @Body() body: unknown, - ): Promise<{ state: Record }> { - const BodySchema = z.object({ state: z.record(z.string(), z.unknown()) }).strict(); - const parsed = BodySchema.safeParse(body); - if (!parsed.success) { - throw new HttpException({ error: 'bad_state_payload' }, HttpStatus.BAD_REQUEST); + ): Promise<{ tools: Array<{ name: string; description: string }>; updatedAt?: string }> { + const node = this.runtime.getNodeInstance(nodeId); + if (!node) { + throw new HttpException({ error: 'node_not_found' }, HttpStatus.NOT_FOUND); + } + if (!(node instanceof LocalMCPServerNode)) { + throw new HttpException({ error: 'node_not_mcp' }, HttpStatus.BAD_REQUEST); + } + try { + await node.discoverTools(); + const snapshot = node.getToolsSnapshot(); + return { + tools: snapshot.tools, + updatedAt: snapshot.updatedAt ? new Date(snapshot.updatedAt).toISOString() : undefined, + }; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new HttpException({ error: msg || 'discover_tools_failed' }, HttpStatus.INTERNAL_SERVER_ERROR); } - const next = parsed.data.state; - await this.nodeState.upsertNodeState(nodeId, next); - return { state: next }; } @Post('nodes/:nodeId/actions') @HttpCode(204) async postNodeAction(@Param('nodeId') nodeId: string, @Body() body: unknown): Promise { try { - const ActionSchema = z.object({ action: z.enum(['provision', 'deprovision']) }); + const ActionSchema = z.object({ action: z.enum(['provision', 'deprovision']) }).strict(); const parsed = ActionSchema.safeParse(body); if (!parsed.success) throw new HttpException({ error: 'bad_action_payload' }, HttpStatus.BAD_REQUEST); const action = parsed.data.action; diff --git a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts b/packages/platform-server/src/graph/controllers/graphPersist.controller.ts deleted file mode 100644 index f8d81f7e9..000000000 --- a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Controller, Get, Inject } from '@nestjs/common'; -import { GraphRepository } from '../graph.repository'; - -@Controller('api') -export class GraphPersistController { - constructor(@Inject(GraphRepository) private readonly graphs: GraphRepository) {} - - @Get('graph') - async getGraph(): Promise<{ name: string; version: number; updatedAt: string; nodes: { id: string; template: string; config?: Record; state?: Record; position?: { x: number; y: number } }[]; edges: { id?: string; source: string; sourceHandle: string; target: string; targetHandle: string }[]; variables?: Array<{ key: string; value: string }> }> { - const name = 'main'; - const graph = await this.graphs.get(name); - if (!graph) { - return { name, version: 0, updatedAt: new Date().toISOString(), nodes: [], edges: [], variables: [] }; - } - return graph; - } -} diff --git a/packages/platform-server/src/graph/controllers/graphVariables.controller.ts b/packages/platform-server/src/graph/controllers/graphVariables.controller.ts deleted file mode 100644 index 4a5030600..000000000 --- a/packages/platform-server/src/graph/controllers/graphVariables.controller.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpException, HttpStatus, Inject, Param, Post, Put } from '@nestjs/common'; -import { GraphVariablesService, VarItem } from '../services/graphVariables.service'; -type CreateBody = { key: string; graph: string }; -type UpdateBody = { graph?: string | null; local?: string | null }; - -@Controller('api/graph/variables') -export class GraphVariablesController { - constructor(@Inject(GraphVariablesService) private readonly service: GraphVariablesService) {} - - @Get() - async list(): Promise<{ items: VarItem[] }> { return this.service.list('main'); } - - @Post() - @HttpCode(201) - async create(@Body() body: unknown): Promise<{ key: string; graph: string }> { - const parsed = parseCreateBody(body); - try { return await this.service.create('main', parsed.key, parsed.graph); } - catch (e: unknown) { - if (isCodeError(e) && e.code === 'DUPLICATE_KEY') throw new HttpException({ error: 'DUPLICATE_KEY' }, HttpStatus.CONFLICT); - throw e; - } - } - - @Put(':key') - async update(@Param('key') key: string, @Body() body: unknown): Promise<{ key: string; graph?: string | null; local?: string | null }> { - const parsed = parseUpdateBody(body); - try { return await this.service.update('main', key, parsed); } - catch (e: unknown) { - if (isCodeError(e) && e.code === 'KEY_NOT_FOUND') throw new HttpException({ error: 'KEY_NOT_FOUND' }, HttpStatus.NOT_FOUND); - throw e; - } - } - - @Delete(':key') - @HttpCode(204) - async remove(@Param('key') key: string): Promise { - await this.service.remove('main', key); - } -} - -function isCodeError(e: unknown): e is { code?: string; current?: unknown } { - return !!e && typeof e === 'object' && 'code' in e; -} - -function parseCreateBody(body: unknown): CreateBody { - if (!body || typeof body !== 'object') throw new HttpException({ error: 'BAD_SCHEMA' }, HttpStatus.BAD_REQUEST); - const obj = body as Record; - const keyRaw = obj['key']; - const graphRaw = obj['graph']; - if (typeof keyRaw !== 'string') throw new HttpException({ error: 'BAD_KEY' }, HttpStatus.BAD_REQUEST); - if (typeof graphRaw !== 'string') throw new HttpException({ error: 'BAD_VALUE' }, HttpStatus.BAD_REQUEST); - const key = keyRaw.trim(); - const graph = graphRaw.trim(); - if (!key) throw new HttpException({ error: 'BAD_KEY' }, HttpStatus.BAD_REQUEST); - if (!graph) throw new HttpException({ error: 'BAD_VALUE' }, HttpStatus.BAD_REQUEST); - return { key, graph }; -} - -function parseUpdateBody(body: unknown): UpdateBody { - if (!body || typeof body !== 'object') throw new HttpException({ error: 'BAD_SCHEMA' }, HttpStatus.BAD_REQUEST); - const obj = body as Record; - const out: { graph?: string | null; local?: string | null } = {}; - if (Object.prototype.hasOwnProperty.call(obj, 'graph')) { - const v = obj['graph']; - if (v == null) throw new HttpException({ error: 'BAD_VALUE' }, HttpStatus.BAD_REQUEST); - if (typeof v !== 'string') throw new HttpException({ error: 'BAD_VALUE' }, HttpStatus.BAD_REQUEST); - const trimmed = v.trim(); - if (!trimmed) throw new HttpException({ error: 'BAD_VALUE' }, HttpStatus.BAD_REQUEST); - out.graph = trimmed; - } - if (Object.prototype.hasOwnProperty.call(obj, 'local')) { - const v = obj['local']; - if (v == null) { out.local = null; } - else if (typeof v === 'string') { out.local = v; } - else throw new HttpException({ error: 'BAD_VALUE' }, HttpStatus.BAD_REQUEST); - } - return out; -} diff --git a/packages/platform-server/src/graph/controllers/variables.controller.ts b/packages/platform-server/src/graph/controllers/variables.controller.ts new file mode 100644 index 000000000..51090c470 --- /dev/null +++ b/packages/platform-server/src/graph/controllers/variables.controller.ts @@ -0,0 +1,127 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpException, HttpStatus, Inject, Param, Post, Put } from '@nestjs/common'; +import { create } from '@bufbuild/protobuf'; +import { z } from 'zod'; +import { listAllPages } from '../../teams/teamsGrpc.pagination'; +import { TEAMS_GRPC_CLIENT } from '../../teams/teamsGrpc.token'; +import type { TeamsGrpcClient } from '../../teams/teamsGrpc.client'; +import { + CreateVariableRequestSchema, + DeleteVariableRequestSchema, + ListVariablesRequestSchema, + UpdateVariableRequestSchema, + type Variable, +} from '../../proto/gen/agynio/api/teams/v1/teams_pb'; + +const CreateVariableSchema = z + .object({ + key: z.string().min(1), + value: z.string(), + description: z.string().optional().default(''), + }) + .strict(); + +const UpdateVariableSchema = z + .object({ + key: z.string().min(1).optional(), + value: z.string().optional(), + description: z.string().optional(), + }) + .strict() + .refine((data) => data.key !== undefined || data.value !== undefined || data.description !== undefined, { + message: 'empty_patch', + }); + +type VariableResponse = { + id: string; + key: string; + value: string; + description: string; +}; + +@Controller('api/graph/variables') +export class VariablesController { + constructor(@Inject(TEAMS_GRPC_CLIENT) private readonly teamsClient: TeamsGrpcClient) {} + + private normalizeId(rawId: string): string { + const id = String(rawId ?? '').trim(); + if (!id) throw new HttpException({ error: 'invalid_variable_id' }, HttpStatus.BAD_REQUEST); + return id; + } + + private normalizeKey(rawKey: string): string { + const key = String(rawKey ?? '').trim(); + if (!key) throw new HttpException({ error: 'invalid_variable_key' }, HttpStatus.BAD_REQUEST); + return key; + } + + private mapVariable(variable: Variable): VariableResponse { + const id = variable.meta?.id; + if (!id) { + throw new HttpException({ error: 'variable_missing_id' }, HttpStatus.INTERNAL_SERVER_ERROR); + } + return { + id, + key: variable.key, + value: variable.value, + description: variable.description, + }; + } + + @Get() + async listVariables(): Promise<{ items: VariableResponse[] }> { + const variables = await listAllPages(async (pageToken, pageSize) => { + const request = create(ListVariablesRequestSchema, { + pageSize, + pageToken: pageToken ?? '', + query: '', + }); + const response = await this.teamsClient.listVariables(request); + return { + items: response.variables ?? [], + nextPageToken: response.nextPageToken ?? undefined, + }; + }); + return { items: variables.map((variable) => this.mapVariable(variable)) }; + } + + @Post() + async createVariable(@Body() body: unknown): Promise { + const parsed = CreateVariableSchema.safeParse(body); + if (!parsed.success) { + throw new HttpException({ error: 'invalid_payload' }, HttpStatus.BAD_REQUEST); + } + const key = this.normalizeKey(parsed.data.key); + const request = create(CreateVariableRequestSchema, { + key, + value: parsed.data.value, + description: parsed.data.description, + }); + const variable = await this.teamsClient.createVariable(request); + return this.mapVariable(variable); + } + + @Put(':id') + async updateVariable(@Param('id') rawId: string, @Body() body: unknown): Promise { + const id = this.normalizeId(rawId); + const parsed = UpdateVariableSchema.safeParse(body); + if (!parsed.success) { + throw new HttpException({ error: 'invalid_payload' }, HttpStatus.BAD_REQUEST); + } + const request = create(UpdateVariableRequestSchema, { + id, + key: parsed.data.key !== undefined ? this.normalizeKey(parsed.data.key) : undefined, + value: parsed.data.value, + description: parsed.data.description, + }); + const variable = await this.teamsClient.updateVariable(request); + return this.mapVariable(variable); + } + + @Delete(':id') + @HttpCode(204) + async deleteVariable(@Param('id') rawId: string): Promise { + const id = this.normalizeId(rawId); + const request = create(DeleteVariableRequestSchema, { id }); + await this.teamsClient.deleteVariable(request); + } +} diff --git a/packages/platform-server/src/graph/graph-api.module.ts b/packages/platform-server/src/graph/graph-api.module.ts index e418a0022..327a313d5 100644 --- a/packages/platform-server/src/graph/graph-api.module.ts +++ b/packages/platform-server/src/graph/graph-api.module.ts @@ -3,31 +3,28 @@ import { AgentsRemindersController } from '../agents/reminders.controller'; import { AgentsThreadsController } from '../agents/threads.controller'; import { ContextItemsController } from '../agents/contextItems.controller'; import { GraphController } from './controllers/graph.controller'; -import { GraphPersistController } from './controllers/graphPersist.controller'; -import { GraphVariablesController } from './controllers/graphVariables.controller'; import { MemoryController } from './controllers/memory.controller'; import { RunsController } from './controllers/runs.controller'; -import { NodeStateService } from './nodeState.service'; -import { GraphVariablesService } from './services/graphVariables.service'; import { GraphDomainModule } from '../graph-domain/graph-domain.module'; import { RemindersController } from './controllers/reminders.controller'; import { EventsModule } from '../events/events.module'; import { GraphCoreModule } from '../graph-core/graph-core.module'; +import { VariablesController } from './controllers/variables.controller'; +import { TeamsModule } from '../teams/teams.module'; @Module({ - imports: [GraphCoreModule, GraphDomainModule, EventsModule], + imports: [GraphCoreModule, GraphDomainModule, EventsModule, TeamsModule], controllers: [ RunsController, - GraphPersistController, GraphController, MemoryController, - GraphVariablesController, AgentsThreadsController, ContextItemsController, AgentsRemindersController, RemindersController, + VariablesController, ], - providers: [NodeStateService, GraphVariablesService], - exports: [GraphCoreModule, NodeStateService, GraphVariablesService], + providers: [], + exports: [GraphCoreModule], }) export class GraphApiModule {} diff --git a/packages/platform-server/src/graph/graph.repository.ts b/packages/platform-server/src/graph/graph.repository.ts index 63a7103a2..40aa64d2d 100644 --- a/packages/platform-server/src/graph/graph.repository.ts +++ b/packages/platform-server/src/graph/graph.repository.ts @@ -11,5 +11,4 @@ export abstract class GraphRepository { abstract initIfNeeded(): Promise; abstract get(name: string): Promise; abstract upsert(req: PersistedGraphUpsertRequest, author?: GraphAuthor): Promise; - abstract upsertNodeState(name: string, nodeId: string, patch: Record): Promise; } diff --git a/packages/platform-server/src/graph/nodeState.service.ts b/packages/platform-server/src/graph/nodeState.service.ts deleted file mode 100644 index 980be5cc2..000000000 --- a/packages/platform-server/src/graph/nodeState.service.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Inject, Injectable, Logger, Scope } from '@nestjs/common'; -import { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; -import { GraphRepository } from './graph.repository'; -import { mergeWith, isArray } from 'lodash-es'; -import { EventsBusService } from '../events/events-bus.service'; - -export function deepMergeNodeState( - prev: Record, - patch: Record, -): Record { - const isPlainObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); - - const result: Record = { ...prev }; - for (const key of Object.keys(patch)) { - const nextVal: unknown = patch[key]; - if (typeof nextVal === 'undefined') continue; // avoid introducing undefined keys - const prevVal: unknown = result[key]; - if (Array.isArray(nextVal)) { - result[key] = nextVal as unknown[]; - } else if (isPlainObject(nextVal) && isPlainObject(prevVal)) { - result[key] = deepMergeNodeState(prevVal, nextVal); - } else { - result[key] = nextVal as Exclude; - } - } - return result; -} - -/** - * Centralized service to persist per-node runtime state && reflect changes in the in-memory runtime snapshot. - * Minimal, non-Nest class to avoid broader DI changes for now. - */ -@Injectable({ scope: Scope.DEFAULT }) -export class NodeStateService { - private readonly logger = new Logger(NodeStateService.name); - - constructor( - @Inject(GraphRepository) private readonly graphRepository: GraphRepository, - @Inject(LiveGraphRuntime) private readonly runtime: LiveGraphRuntime, - @Inject(EventsBusService) private readonly eventsBus: EventsBusService, - ) {} - - /** Return last known runtime snapshot for a node (for filtering). */ - getSnapshot(nodeId: string): Record | undefined { - return this.runtime.getNodeStateSnapshot(nodeId); - } - - async upsertNodeState(nodeId: string, patch: Record, name = 'main'): Promise { - try { - // Deep-merge previous snapshot with incoming patch (arrays replace) - const prev = this.runtime.getNodeStateSnapshot(nodeId) || {}; - const merged = mergeWith({}, prev, patch, (objValue, srcValue) => { - if (isArray(objValue) && isArray(srcValue)) return srcValue; - return undefined; - }); - // Persist merged via repository, update runtime with merged - await this.graphRepository.upsertNodeState(name, nodeId, merged); - this.runtime.updateNodeState(nodeId, merged); - // Invoke node instance setState with merged snapshot for runtime reactions - const inst = this.runtime.getNodeInstance(nodeId); - try { - await inst?.setState?.(merged as Record); - } catch (e) { - this.logger.error( - `NodeStateService: instance.setState failed ${JSON.stringify({ nodeId, error: String(e) })}`, - ); - } - this.eventsBus.emitNodeState({ nodeId, state: merged, updatedAtMs: Date.now() }); - } catch (e) { - this.logger.error( - `NodeStateService: upsertNodeState failed ${JSON.stringify({ nodeId, error: String(e) })}`, - ); - } - } -} diff --git a/packages/platform-server/src/graph/services/graphVariables.service.ts b/packages/platform-server/src/graph/services/graphVariables.service.ts deleted file mode 100644 index e05623263..000000000 --- a/packages/platform-server/src/graph/services/graphVariables.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { PrismaService } from '../../core/services/prisma.service'; - -export type VarItem = { key: string; graph: string | null; local: string | null }; - -// Service encapsulates business logic for graph variables operations -@Injectable() -export class GraphVariablesService { - constructor(@Inject(PrismaService) private readonly prismaService: PrismaService) {} - - async list(_name = 'main'): Promise<{ items: VarItem[] }> { - const prisma = this.prismaService.getClient(); - const [graphVars, locals] = await Promise.all([ - prisma.graphVariable.findMany(), - prisma.variableLocal.findMany(), - ]); - const itemsMap = new Map(); - for (const v of graphVars) { - itemsMap.set(v.key, { key: v.key, graph: v.value, local: null }); - } - for (const lv of locals) { - const existing = itemsMap.get(lv.key); - if (existing) existing.local = lv.value; - else itemsMap.set(lv.key, { key: lv.key, graph: null, local: lv.value }); - } - return { items: Array.from(itemsMap.values()) }; - } - - async create(_name: string, key: string, graphValue: string): Promise<{ key: string; graph: string }> { - const prisma = this.prismaService.getClient(); - const existing = await prisma.graphVariable.findUnique({ where: { key } }); - if (existing) throw Object.assign(new Error('Duplicate key'), { code: 'DUPLICATE_KEY' }); - await prisma.graphVariable.create({ data: { key, value: graphValue } }); - return { key, graph: graphValue }; - } - - async update(_name: string, key: string, req: { graph?: string; local?: string | null }): Promise<{ key: string; graph?: string | null; local?: string | null }> { - const prisma = this.prismaService.getClient(); - if (req.graph !== undefined) { - const current = await prisma.graphVariable.findUnique({ where: { key } }); - if (!current) throw Object.assign(new Error('Key not found'), { code: 'KEY_NOT_FOUND' }); - await prisma.graphVariable.update({ where: { key }, data: { value: req.graph } }); - } - // Local override update - if (req.local !== undefined) { - const val = (req.local ?? '').trim(); - if (!val) await prisma.variableLocal.deleteMany({ where: { key } }); - else await prisma.variableLocal.upsert({ where: { key }, update: { value: val }, create: { key, value: val } }); - } - const out: { key: string; graph?: string | null; local?: string | null } = { key }; - if (req.graph !== undefined) out.graph = req.graph; - if (req.local !== undefined) { - const normalizedLocal = (req.local ?? '').trim(); - out.local = normalizedLocal ? normalizedLocal : null; - } - return out; - } - - async remove(_name: string, key: string): Promise { - const prisma = this.prismaService.getClient(); - await prisma.graphVariable.deleteMany({ where: { key } }); - await prisma.variableLocal.deleteMany({ where: { key } }); - } - - async resolveValue(_graphName: string, key: string): Promise { - const prisma = this.prismaService.getClient(); - const local = await prisma.variableLocal.findUnique({ where: { key } }); - const localValue = local?.value ?? null; - if (typeof localValue === 'string' && localValue.length > 0) return localValue; - - const graphValue = await prisma.graphVariable.findUnique({ where: { key } }); - const value = graphValue?.value ?? null; - if (typeof value === 'string' && value.length > 0) return value; - return undefined; - } -} diff --git a/packages/platform-server/src/graph/teamsGraph.repository.ts b/packages/platform-server/src/graph/teamsGraph.repository.ts index cc31e58c4..ec9a96616 100644 --- a/packages/platform-server/src/graph/teamsGraph.repository.ts +++ b/packages/platform-server/src/graph/teamsGraph.repository.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { PrismaService } from '../core/services/prisma.service'; import type { PersistedGraph, PersistedGraphUpsertRequest, @@ -9,20 +8,10 @@ import type { GraphAuthor } from './graph.repository'; import { GraphRepository } from './graph.repository'; import { TeamsGraphSource } from './teamsGraph.source'; -type PersistedNodeState = Record; - -const readNodeState = (value: unknown): PersistedNodeState | undefined => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - return value as PersistedNodeState; -}; - @Injectable() export class TeamsGraphRepository extends GraphRepository { constructor( @Inject(TeamsGraphSource) private readonly teamsSource: TeamsGraphSource, - @Inject(PrismaService) private readonly prismaService: PrismaService, ) { super(); } @@ -37,33 +26,12 @@ export class TeamsGraphRepository extends GraphRepository { if (snapshot.nodes.length === 0 && snapshot.edges.length === 0) { return null; } - - const prisma = this.prismaService.getClient(); - const [states, variables] = await Promise.all([ - prisma.graphNodeState.findMany(), - prisma.graphVariable.findMany(), - ]); - - const stateByNodeId = new Map(); - for (const entry of states) { - const state = readNodeState(entry.state); - if (!state) continue; - stateByNodeId.set(entry.nodeId, state); - } - - const nodes = snapshot.nodes.map((node) => { - const state = stateByNodeId.get(node.id); - if (!state) return node; - return { ...node, state }; - }); - return { name, version: 0, updatedAt: new Date().toISOString(), - nodes, + nodes: snapshot.nodes, edges: snapshot.edges, - variables: variables.map((variable) => ({ key: variable.key, value: variable.value })), } satisfies PersistedGraph; } @@ -75,13 +43,4 @@ export class TeamsGraphRepository extends GraphRepository { err.code = 'GRAPH_READ_ONLY'; throw err; } - - async upsertNodeState(_name: string, nodeId: string, patch: Record): Promise { - const prisma = this.prismaService.getClient(); - await prisma.graphNodeState.upsert({ - where: { nodeId }, - update: { state: patch }, - create: { nodeId, state: patch }, - }); - } } diff --git a/packages/platform-server/src/graph/teamsGraph.source.ts b/packages/platform-server/src/graph/teamsGraph.source.ts index e5130815e..cc04c0a79 100644 --- a/packages/platform-server/src/graph/teamsGraph.source.ts +++ b/packages/platform-server/src/graph/teamsGraph.source.ts @@ -20,6 +20,8 @@ import { ListMemoryBucketsRequestSchema, ListToolsRequestSchema, ListWorkspaceConfigurationsRequestSchema, + McpToolFilter, + McpToolFilterMode, McpServer, MemoryBucket, MemoryBucketScope, @@ -102,13 +104,13 @@ export class TeamsGraphSource { }; for (const agent of agents) { - const id = this.normalizeId(agent.id); + const id = this.normalizeId(agent.meta?.id); if (!id) continue; addNode({ id, template: 'agent', config: this.mapAgentConfig(agent) }); } for (const tool of tools) { - const id = this.normalizeId(tool.id); + const id = this.normalizeId(tool.meta?.id); if (!id) continue; const template = TOOL_TYPE_TO_TEMPLATE[tool.type]; if (!template) continue; @@ -117,19 +119,19 @@ export class TeamsGraphSource { } for (const mcp of mcps) { - const id = this.normalizeId(mcp.id); + const id = this.normalizeId(mcp.meta?.id); if (!id) continue; addNode({ id, template: 'mcpServer', config: this.mapMcpConfig(mcp) }); } for (const workspace of workspaces) { - const id = this.normalizeId(workspace.id); + const id = this.normalizeId(workspace.meta?.id); if (!id) continue; addNode({ id, template: 'workspace', config: this.mapWorkspaceConfig(workspace) }); } for (const memory of memoryBuckets) { - const id = this.normalizeId(memory.id); + const id = this.normalizeId(memory.meta?.id); if (!id) continue; addNode({ id, template: 'memory', config: this.mapMemoryBucketConfig(memory) }); } @@ -199,19 +201,23 @@ export class TeamsGraphSource { } private async listAllAgents(): Promise { - return listAllPages((page, perPage) => - this.teams.listAgents(create(ListAgentsRequestSchema, { page, perPage })), + return listAllPages((pageToken, pageSize) => + this.teams + .listAgents(create(ListAgentsRequestSchema, { pageSize, pageToken: pageToken ?? '' })) + .then((response) => ({ items: response.agents, nextPageToken: response.nextPageToken })), ); } private async listAllTools(): Promise { - const items = await listAllPages((page, perPage) => - this.teams.listTools(create(ListToolsRequestSchema, { page, perPage })), + const items = await listAllPages((pageToken, pageSize) => + this.teams + .listTools(create(ListToolsRequestSchema, { pageSize, pageToken: pageToken ?? '' })) + .then((response) => ({ items: response.tools, nextPageToken: response.nextPageToken })), ); const collected = new Map(); for (const tool of items) { if (!TOOL_TYPE_TO_TEMPLATE[tool.type]) continue; - const id = this.normalizeId(tool.id); + const id = this.normalizeId(tool.meta?.id); if (!id || collected.has(id)) continue; collected.set(id, tool); } @@ -219,39 +225,49 @@ export class TeamsGraphSource { } private async listAllMcpServers(): Promise { - return listAllPages((page, perPage) => - this.teams.listMcpServers(create(ListMcpServersRequestSchema, { page, perPage })), + return listAllPages((pageToken, pageSize) => + this.teams + .listMcpServers(create(ListMcpServersRequestSchema, { pageSize, pageToken: pageToken ?? '' })) + .then((response) => ({ items: response.mcpServers, nextPageToken: response.nextPageToken })), ); } private async listAllWorkspaces(): Promise { - return listAllPages((page, perPage) => - this.teams.listWorkspaceConfigurations(create(ListWorkspaceConfigurationsRequestSchema, { page, perPage })), + return listAllPages((pageToken, pageSize) => + this.teams + .listWorkspaceConfigurations( + create(ListWorkspaceConfigurationsRequestSchema, { pageSize, pageToken: pageToken ?? '' }), + ) + .then((response) => ({ items: response.workspaceConfigurations, nextPageToken: response.nextPageToken })), ); } private async listAllMemoryBuckets(): Promise { - return listAllPages((page, perPage) => - this.teams.listMemoryBuckets(create(ListMemoryBucketsRequestSchema, { page, perPage })), + return listAllPages((pageToken, pageSize) => + this.teams + .listMemoryBuckets(create(ListMemoryBucketsRequestSchema, { pageSize, pageToken: pageToken ?? '' })) + .then((response) => ({ items: response.memoryBuckets, nextPageToken: response.nextPageToken })), ); } private async listAllAttachments(): Promise { const collected = new Map(); for (const filter of ATTACHMENT_FILTERS) { - const items = await listAllPages((page, perPage) => - this.teams.listAttachments( - create(ListAttachmentsRequestSchema, { - kind: filter.kind, - sourceType: filter.sourceType, - targetType: filter.targetType, - page, - perPage, - }), - ), + const items = await listAllPages((pageToken, pageSize) => + this.teams + .listAttachments( + create(ListAttachmentsRequestSchema, { + kind: filter.kind, + sourceType: filter.sourceType, + targetType: filter.targetType, + pageSize, + pageToken: pageToken ?? '', + }), + ) + .then((response) => ({ items: response.attachments, nextPageToken: response.nextPageToken })), ); for (const attachment of items) { - const key = this.normalizeId(attachment.id) ?? `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; + const key = this.normalizeId(attachment.meta?.id) ?? `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; if (collected.has(key)) continue; collected.set(key, attachment); } @@ -343,6 +359,8 @@ export class TeamsGraphSource { if (backoffMs !== undefined) restart.backoffMs = backoffMs; if (Object.keys(restart).length > 0) config.restart = restart; } + const toolFilter = this.mapMcpToolFilter(raw.toolFilter); + if (toolFilter) config.toolFilter = toolFilter; } return Object.keys(config).length > 0 ? config : undefined; } @@ -394,6 +412,30 @@ export class TeamsGraphSource { return Object.keys(config).length > 0 ? config : undefined; } + private mapMcpToolFilter( + filter: McpToolFilter | undefined, + ): { mode: 'allow' | 'deny'; rules: Array<{ pattern: string }> } | undefined { + if (!filter) return undefined; + const mode = this.mapMcpToolFilterMode(filter.mode); + if (!mode) return undefined; + const rules = filter.rules + .map((rule) => readString(rule.pattern)) + .filter((pattern): pattern is string => Boolean(pattern)) + .map((pattern) => ({ pattern })); + return { mode, rules }; + } + + private mapMcpToolFilterMode(value: McpToolFilterMode): 'allow' | 'deny' | undefined { + switch (value) { + case McpToolFilterMode.ALLOW: + return 'allow'; + case McpToolFilterMode.DENY: + return 'deny'; + default: + return undefined; + } + } + private mapWhenBusy(value: AgentWhenBusy): 'wait' | 'injectAfterTools' | undefined { switch (value) { case AgentWhenBusy.WAIT: diff --git a/packages/platform-server/src/nodes/mcp/localMcpServer.node.ts b/packages/platform-server/src/nodes/mcp/localMcpServer.node.ts index 18c83dbea..48ac599d5 100644 --- a/packages/platform-server/src/nodes/mcp/localMcpServer.node.ts +++ b/packages/platform-server/src/nodes/mcp/localMcpServer.node.ts @@ -8,13 +8,11 @@ import { ConfigService } from '../../core/services/config.service'; import { EnvService, type EnvItem } from '../../env/env.service'; import { WorkspaceExecTransport } from './workspaceExecTransport'; import { LocalMCPServerTool } from './localMcpServer.tool'; -import { DEFAULT_MCP_COMMAND, McpError, type McpTool, McpToolCallResult, PersistedMcpState } from './types'; -import { NodeStateService } from '../../graph/nodeState.service'; +import { DEFAULT_MCP_COMMAND, McpError, type McpTool, McpToolCallResult } from './types'; import Node from '../base/Node'; import { Inject, Injectable, Scope } from '@nestjs/common'; import { jsonSchemaToZod } from '@agyn/json-schema-to-zod'; -import { isEqual } from 'lodash-es'; -import { ModuleRef } from '@nestjs/core'; +import picomatch from 'picomatch'; import { ReferenceValueSchema } from '../../utils/reference-schemas'; const EnvItemSchema = z @@ -25,6 +23,19 @@ const EnvItemSchema = z .strict() .describe('Environment variable entry (static string or reference).'); +const ToolFilterRuleSchema = z + .object({ + pattern: z.string().min(1), + }) + .strict(); + +const ToolFilterSchema = z + .object({ + mode: z.enum(['allow', 'deny']), + rules: z.array(ToolFilterRuleSchema).default([]), + }) + .strict(); + export const LocalMcpServerStaticConfigSchema = z.object({ title: z.string().optional(), namespace: z.string().min(1).optional().default('').describe('Namespace prefix for exposed MCP tools.'), @@ -49,6 +60,7 @@ export const LocalMcpServerStaticConfigSchema = z.object({ }) .optional() .describe('Restart strategy configuration (optional).'), + toolFilter: ToolFilterSchema.optional().describe('Optional tool allow/deny filter for MCP tools.'), }); // .strict(); @@ -89,21 +101,15 @@ export class LocalMCPServerNode extends Node boolean> | null = null; // Debug / tracing counters // Node lifecycle state driven by base Node private _provInFlight: Promise | null = null; - // Dynamic config: enabled tools (undefined => disabled by default) - // Tools are exposed only after enabledTools explicitly enumerates them. - private _globalStaleTimeoutMs = 0; - // Last seen enabled tools from state for change detection - private _lastEnabledTools?: string[]; - constructor( @Inject(EnvService) protected envService: EnvService, @Inject(ConfigService) protected configService: ConfigService, - @Inject(ModuleRef) private readonly moduleRef: ModuleRef, ) { super(); } @@ -113,19 +119,6 @@ export class LocalMCPServerNode extends Node): Promise { + const parsed = LocalMcpServerStaticConfigSchema.parse(cfg ?? {}); + this.toolFilterMatchers = this.buildToolFilterMatchers(parsed.toolFilter); + await super.setConfig(parsed); + this.notifyToolsUpdated(Date.now()); + } + /** * Create a LocalMCPServerTool instance from a McpTool. * If a delegate is provided, it is used (for discovered tools); otherwise, a fallback delegate is used (for preloaded tools). @@ -149,45 +149,18 @@ export class LocalMCPServerNode extends Node this.createLocalTool(t)); - this.toolsDiscovered = true; // consider discovered for initial dynamic schema availability - - if (updatedAt !== undefined) { - const ts = - typeof updatedAt === 'number' - ? updatedAt - : updatedAt instanceof Date - ? updatedAt.getTime() - : Date.parse(String(updatedAt)); - if (Number.isFinite(ts)) this.lastToolsUpdatedAt = ts; - } - // Notify listeners with unified tools update event - this.notifyToolsUpdated(Date.now()); - } - - async setState(state: { mcp?: PersistedMcpState }): Promise { - // Preload cached tools if present in state - if (state?.mcp && state.mcp.tools) { - const summaries = state.mcp.tools; - const updatedAt = state.mcp.toolsUpdatedAt; + private buildToolFilterMatchers( + filter: z.infer | undefined, + ): Array<(name: string) => boolean> | null { + if (!filter) return null; + if (!filter.rules.length) return []; + return filter.rules.map((rule) => { try { - this.preloadCachedTools(summaries, updatedAt); - } catch (e) { - this.logger.error(`Error during MCP cache preload for node ${this.nodeId}: ${this.formatError(e)}`); + return picomatch(rule.pattern); + } catch (_err) { + throw new Error(`invalid_mcp_tool_filter_pattern: ${rule.pattern}`); } - } - // Detect enabledTools changes in state.mcp (optional field) - const mcpState = state?.mcp as Record | undefined; - const rawEnabled: unknown = mcpState ? (mcpState['enabledTools'] as unknown) : undefined; - const nextEnabled = - Array.isArray(rawEnabled) && rawEnabled.every((v) => typeof v === 'string') - ? (rawEnabled as string[]) - : undefined; - if (!isEqual(this._lastEnabledTools, nextEnabled)) { - this._lastEnabledTools = nextEnabled ? [...nextEnabled] : undefined; - this.notifyToolsUpdated(Date.now()); - } + }); } /** @@ -242,29 +215,6 @@ export class LocalMCPServerNode extends Node - ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - outputSchema: t.outputSchema, - }) as McpTool, - ), - toolsUpdatedAt: this.lastToolsUpdatedAt, - }, - }; - const nodeStateService = this.getNodeStateService(); - if (nodeStateService) { - await nodeStateService.upsertNodeState(this.nodeId, state as Record); - } - } catch (e) { - this.logger.error(`[MCP:${this.config.namespace}] Failed to persist state error=${this.formatError(e)}`); - } // Notify listeners with unified tools update event this.notifyToolsUpdated(this.lastToolsUpdatedAt || Date.now()); } catch (err) { @@ -307,54 +257,31 @@ export class LocalMCPServerNode extends Node { - const prefix = ns ? `${ns}_` : ''; - if (prefix && name.startsWith(prefix)) return name; // already namespaced for this server - if (!prefix) return name; // no namespace -> runtime == raw - // Accept raw names and map to runtime namespaced form - return `${prefix}${name}`; + getToolsSnapshot(): { tools: Array<{ name: string; description: string }>; updatedAt?: number } { + const tools = this.applyToolFilter(this.toolsCache ? [...this.toolsCache] : []); + return { + tools: tools.map((tool) => ({ name: tool.name, description: tool.description })), + updatedAt: this.lastToolsUpdatedAt, }; + } - // Prefer NodeStateService snapshot - let enabledList: string[] | undefined; - try { - const snap = this.getNodeStateService()?.getSnapshot(this.nodeId) as - | { mcp?: { enabledTools?: string[] } } - | undefined; - if (snap && snap.mcp && Array.isArray(snap.mcp.enabledTools)) { - enabledList = [...snap.mcp.enabledTools]; - } - } catch { - // ignore snapshot errors - } - - // Fallback to last enabledTools captured via setState if snapshot not ready - if (!enabledList && Array.isArray(this._lastEnabledTools)) { - enabledList = [...this._lastEnabledTools]; - } + // Return legacy McpTool shape for interface compliance; callers needing function tools can access toolsCache directly. + listTools(_force = false): LocalMCPServerTool[] { + return this.applyToolFilter(this.toolsCache ? [...this.toolsCache] : []); + } - if (enabledList === undefined) { - return []; + private applyToolFilter(tools: LocalMCPServerTool[]): LocalMCPServerTool[] { + const filter = this.config?.toolFilter; + if (!filter) return tools; + const matchers = this.toolFilterMatchers ?? []; + if (matchers.length === 0) { + return filter.mode === 'allow' ? [] : tools; } - - const wantedRuntimeNames = new Set(enabledList.map((n) => toRuntimeName(String(n)))); - const availableNames = new Set(allTools.map((t) => t.name)); - // Log and ignore unknown names - const unknown: string[] = Array.from(wantedRuntimeNames).filter((n) => !availableNames.has(n)); - if (unknown.length) { - const availableList = Array.from(availableNames).join(','); - this.logger.log( - `[MCP:${ns}] enabledTools contains unknown tool(s); ignoring unknown=${unknown.join(',')} available=${availableList}`, - ); + const matches = (tool: LocalMCPServerTool) => matchers.some((matcher) => matcher(tool.rawName)); + if (filter.mode === 'allow') { + return tools.filter(matches); } - return allTools.filter((t) => wantedRuntimeNames.has(t.name)); + return tools.filter((tool) => !matches(tool)); } async callTool( diff --git a/packages/platform-server/src/nodes/mcp/localMcpServer.tool.ts b/packages/platform-server/src/nodes/mcp/localMcpServer.tool.ts index fddb5b8c4..f20f3b6a7 100644 --- a/packages/platform-server/src/nodes/mcp/localMcpServer.tool.ts +++ b/packages/platform-server/src/nodes/mcp/localMcpServer.tool.ts @@ -31,6 +31,9 @@ export class LocalMCPServerTool extends FunctionTool { get name() { return this.node.config.namespace ? `${this.node.config.namespace}_${this._name}` : this._name; } + get rawName() { + return this._name; + } get description() { return this._description; } diff --git a/packages/platform-server/src/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts index 5a3d37cdb..9a21628d2 100644 --- a/packages/platform-server/src/proto/grpc.ts +++ b/packages/platform-server/src/proto/grpc.ts @@ -2,7 +2,6 @@ import { makeGenericClientConstructor } from '@grpc/grpc-js'; import type { MethodDefinition, ServiceDefinition } from '@grpc/grpc-js'; import { toBinary, fromBinary } from '@bufbuild/protobuf'; import type { DescMessage } from '@bufbuild/protobuf'; -import { EmptySchema } from '@bufbuild/protobuf/wkt'; import { CancelExecutionRequestSchema, CancelExecutionResponseSchema, @@ -37,44 +36,72 @@ import { } from './gen/agynio/api/runner/v1/runner_pb.js'; import { AgentCreateRequestSchema, - AgentSchema, AgentUpdateRequestSchema, AttachmentCreateRequestSchema, - AttachmentSchema, + CreateAgentResponseSchema, + CreateAttachmentResponseSchema, + CreateMcpServerResponseSchema, + CreateMemoryBucketResponseSchema, + CreateToolResponseSchema, + CreateVariableRequestSchema, + CreateVariableResponseSchema, + CreateWorkspaceConfigurationResponseSchema, DeleteAgentRequestSchema, + DeleteAgentResponseSchema, DeleteAttachmentRequestSchema, + DeleteAttachmentResponseSchema, DeleteMcpServerRequestSchema, + DeleteMcpServerResponseSchema, DeleteMemoryBucketRequestSchema, + DeleteMemoryBucketResponseSchema, DeleteToolRequestSchema, + DeleteToolResponseSchema, + DeleteVariableRequestSchema, + DeleteVariableResponseSchema, DeleteWorkspaceConfigurationRequestSchema, + DeleteWorkspaceConfigurationResponseSchema, GetAgentRequestSchema, + GetAgentResponseSchema, GetMcpServerRequestSchema, + GetMcpServerResponseSchema, GetMemoryBucketRequestSchema, + GetMemoryBucketResponseSchema, GetToolRequestSchema, + GetToolResponseSchema, + GetVariableRequestSchema, + GetVariableResponseSchema, GetWorkspaceConfigurationRequestSchema, + GetWorkspaceConfigurationResponseSchema, ListAgentsRequestSchema, + ListAgentsResponseSchema, ListAttachmentsRequestSchema, + ListAttachmentsResponseSchema, ListMcpServersRequestSchema, + ListMcpServersResponseSchema, ListMemoryBucketsRequestSchema, + ListMemoryBucketsResponseSchema, ListToolsRequestSchema, + ListToolsResponseSchema, + ListVariablesRequestSchema, + ListVariablesResponseSchema, ListWorkspaceConfigurationsRequestSchema, + ListWorkspaceConfigurationsResponseSchema, McpServerCreateRequestSchema, - McpServerSchema, McpServerUpdateRequestSchema, MemoryBucketCreateRequestSchema, - MemoryBucketSchema, MemoryBucketUpdateRequestSchema, - PaginatedAgentsSchema, - PaginatedAttachmentsSchema, - PaginatedMcpServersSchema, - PaginatedMemoryBucketsSchema, - PaginatedToolsSchema, - PaginatedWorkspaceConfigurationsSchema, + ResolveVariableRequestSchema, + ResolveVariableResponseSchema, ToolCreateRequestSchema, - ToolSchema, ToolUpdateRequestSchema, + UpdateAgentResponseSchema, + UpdateMcpServerResponseSchema, + UpdateMemoryBucketResponseSchema, + UpdateToolResponseSchema, + UpdateVariableRequestSchema, + UpdateVariableResponseSchema, + UpdateWorkspaceConfigurationResponseSchema, WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationSchema, WorkspaceConfigurationUpdateRequestSchema, } from './gen/agynio/api/teams/v1/teams_pb.js'; @@ -254,6 +281,12 @@ export const TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.Tea export const TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/GetMemoryBucket'; export const TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/UpdateMemoryBucket'; export const TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH = '/agynio.api.teams.v1.TeamsService/DeleteMemoryBucket'; +export const TEAMS_SERVICE_LIST_VARIABLES_PATH = '/agynio.api.teams.v1.TeamsService/ListVariables'; +export const TEAMS_SERVICE_CREATE_VARIABLE_PATH = '/agynio.api.teams.v1.TeamsService/CreateVariable'; +export const TEAMS_SERVICE_GET_VARIABLE_PATH = '/agynio.api.teams.v1.TeamsService/GetVariable'; +export const TEAMS_SERVICE_UPDATE_VARIABLE_PATH = '/agynio.api.teams.v1.TeamsService/UpdateVariable'; +export const TEAMS_SERVICE_DELETE_VARIABLE_PATH = '/agynio.api.teams.v1.TeamsService/DeleteVariable'; +export const TEAMS_SERVICE_RESOLVE_VARIABLE_PATH = '/agynio.api.teams.v1.TeamsService/ResolveVariable'; export const TEAMS_SERVICE_LIST_ATTACHMENTS_PATH = '/agynio.api.teams.v1.TeamsService/ListAttachments'; export const TEAMS_SERVICE_CREATE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/CreateAttachment'; export const TEAMS_SERVICE_DELETE_ATTACHMENT_PATH = '/agynio.api.teams.v1.TeamsService/DeleteAttachment'; @@ -262,142 +295,172 @@ export const teamsServiceGrpcDefinition: ServiceDefinition = { listAgents: unaryDefinition( TEAMS_SERVICE_LIST_AGENTS_PATH, ListAgentsRequestSchema, - PaginatedAgentsSchema, + ListAgentsResponseSchema, ), createAgent: unaryDefinition( TEAMS_SERVICE_CREATE_AGENT_PATH, AgentCreateRequestSchema, - AgentSchema, + CreateAgentResponseSchema, ), getAgent: unaryDefinition( TEAMS_SERVICE_GET_AGENT_PATH, GetAgentRequestSchema, - AgentSchema, + GetAgentResponseSchema, ), updateAgent: unaryDefinition( TEAMS_SERVICE_UPDATE_AGENT_PATH, AgentUpdateRequestSchema, - AgentSchema, + UpdateAgentResponseSchema, ), deleteAgent: unaryDefinition( TEAMS_SERVICE_DELETE_AGENT_PATH, DeleteAgentRequestSchema, - EmptySchema, + DeleteAgentResponseSchema, ), listTools: unaryDefinition( TEAMS_SERVICE_LIST_TOOLS_PATH, ListToolsRequestSchema, - PaginatedToolsSchema, + ListToolsResponseSchema, ), createTool: unaryDefinition( TEAMS_SERVICE_CREATE_TOOL_PATH, ToolCreateRequestSchema, - ToolSchema, + CreateToolResponseSchema, ), getTool: unaryDefinition( TEAMS_SERVICE_GET_TOOL_PATH, GetToolRequestSchema, - ToolSchema, + GetToolResponseSchema, ), updateTool: unaryDefinition( TEAMS_SERVICE_UPDATE_TOOL_PATH, ToolUpdateRequestSchema, - ToolSchema, + UpdateToolResponseSchema, ), deleteTool: unaryDefinition( TEAMS_SERVICE_DELETE_TOOL_PATH, DeleteToolRequestSchema, - EmptySchema, + DeleteToolResponseSchema, ), listMcpServers: unaryDefinition( TEAMS_SERVICE_LIST_MCP_SERVERS_PATH, ListMcpServersRequestSchema, - PaginatedMcpServersSchema, + ListMcpServersResponseSchema, ), createMcpServer: unaryDefinition( TEAMS_SERVICE_CREATE_MCP_SERVER_PATH, McpServerCreateRequestSchema, - McpServerSchema, + CreateMcpServerResponseSchema, ), getMcpServer: unaryDefinition( TEAMS_SERVICE_GET_MCP_SERVER_PATH, GetMcpServerRequestSchema, - McpServerSchema, + GetMcpServerResponseSchema, ), updateMcpServer: unaryDefinition( TEAMS_SERVICE_UPDATE_MCP_SERVER_PATH, McpServerUpdateRequestSchema, - McpServerSchema, + UpdateMcpServerResponseSchema, ), deleteMcpServer: unaryDefinition( TEAMS_SERVICE_DELETE_MCP_SERVER_PATH, DeleteMcpServerRequestSchema, - EmptySchema, + DeleteMcpServerResponseSchema, ), listWorkspaceConfigurations: unaryDefinition( TEAMS_SERVICE_LIST_WORKSPACE_CONFIGURATIONS_PATH, ListWorkspaceConfigurationsRequestSchema, - PaginatedWorkspaceConfigurationsSchema, + ListWorkspaceConfigurationsResponseSchema, ), createWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_CREATE_WORKSPACE_CONFIGURATION_PATH, WorkspaceConfigurationCreateRequestSchema, - WorkspaceConfigurationSchema, + CreateWorkspaceConfigurationResponseSchema, ), getWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_GET_WORKSPACE_CONFIGURATION_PATH, GetWorkspaceConfigurationRequestSchema, - WorkspaceConfigurationSchema, + GetWorkspaceConfigurationResponseSchema, ), updateWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_UPDATE_WORKSPACE_CONFIGURATION_PATH, WorkspaceConfigurationUpdateRequestSchema, - WorkspaceConfigurationSchema, + UpdateWorkspaceConfigurationResponseSchema, ), deleteWorkspaceConfiguration: unaryDefinition( TEAMS_SERVICE_DELETE_WORKSPACE_CONFIGURATION_PATH, DeleteWorkspaceConfigurationRequestSchema, - EmptySchema, + DeleteWorkspaceConfigurationResponseSchema, ), listMemoryBuckets: unaryDefinition( TEAMS_SERVICE_LIST_MEMORY_BUCKETS_PATH, ListMemoryBucketsRequestSchema, - PaginatedMemoryBucketsSchema, + ListMemoryBucketsResponseSchema, ), createMemoryBucket: unaryDefinition( TEAMS_SERVICE_CREATE_MEMORY_BUCKET_PATH, MemoryBucketCreateRequestSchema, - MemoryBucketSchema, + CreateMemoryBucketResponseSchema, ), getMemoryBucket: unaryDefinition( TEAMS_SERVICE_GET_MEMORY_BUCKET_PATH, GetMemoryBucketRequestSchema, - MemoryBucketSchema, + GetMemoryBucketResponseSchema, ), updateMemoryBucket: unaryDefinition( TEAMS_SERVICE_UPDATE_MEMORY_BUCKET_PATH, MemoryBucketUpdateRequestSchema, - MemoryBucketSchema, + UpdateMemoryBucketResponseSchema, ), deleteMemoryBucket: unaryDefinition( TEAMS_SERVICE_DELETE_MEMORY_BUCKET_PATH, DeleteMemoryBucketRequestSchema, - EmptySchema, + DeleteMemoryBucketResponseSchema, + ), + listVariables: unaryDefinition( + TEAMS_SERVICE_LIST_VARIABLES_PATH, + ListVariablesRequestSchema, + ListVariablesResponseSchema, + ), + createVariable: unaryDefinition( + TEAMS_SERVICE_CREATE_VARIABLE_PATH, + CreateVariableRequestSchema, + CreateVariableResponseSchema, + ), + getVariable: unaryDefinition( + TEAMS_SERVICE_GET_VARIABLE_PATH, + GetVariableRequestSchema, + GetVariableResponseSchema, + ), + updateVariable: unaryDefinition( + TEAMS_SERVICE_UPDATE_VARIABLE_PATH, + UpdateVariableRequestSchema, + UpdateVariableResponseSchema, + ), + deleteVariable: unaryDefinition( + TEAMS_SERVICE_DELETE_VARIABLE_PATH, + DeleteVariableRequestSchema, + DeleteVariableResponseSchema, + ), + resolveVariable: unaryDefinition( + TEAMS_SERVICE_RESOLVE_VARIABLE_PATH, + ResolveVariableRequestSchema, + ResolveVariableResponseSchema, ), listAttachments: unaryDefinition( TEAMS_SERVICE_LIST_ATTACHMENTS_PATH, ListAttachmentsRequestSchema, - PaginatedAttachmentsSchema, + ListAttachmentsResponseSchema, ), createAttachment: unaryDefinition( TEAMS_SERVICE_CREATE_ATTACHMENT_PATH, AttachmentCreateRequestSchema, - AttachmentSchema, + CreateAttachmentResponseSchema, ), deleteAttachment: unaryDefinition( TEAMS_SERVICE_DELETE_ATTACHMENT_PATH, DeleteAttachmentRequestSchema, - EmptySchema, + DeleteAttachmentResponseSchema, ), }; diff --git a/packages/platform-server/src/shared/types/graph.types.ts b/packages/platform-server/src/shared/types/graph.types.ts index c5c6bef7b..7163dd873 100644 --- a/packages/platform-server/src/shared/types/graph.types.ts +++ b/packages/platform-server/src/shared/types/graph.types.ts @@ -11,7 +11,6 @@ export interface NodeDef { data: { template: string; // template name registered in TemplateRegistry config?: Record; // optional configuration passed via instance.setConfig - state?: Record; // optional persisted runtime state (per-node) }; } @@ -108,7 +107,6 @@ export interface PersistedGraphNode { id: string; template: string; config?: Record; - state?: Record; position?: { x: number; y: number }; // UI hint, optional server side } export interface PersistedGraphEdge { @@ -124,16 +122,11 @@ export interface PersistedGraph { updatedAt: string; // ISO timestamp nodes: PersistedGraphNode[]; edges: PersistedGraphEdge[]; - // Optional graph-level variables (Issue #543) - // Keys must be unique; values are plain strings. - variables?: Array<{ key: string; value: string }>; } export interface PersistedGraphUpsertRequest { name: string; version?: number; // expected version (undefined => create) nodes: PersistedGraphNode[]; edges: PersistedGraphEdge[]; - // Optional variables; if omitted, repositories must preserve existing values. - variables?: Array<{ key: string; value: string }>; } export type PersistedGraphUpsertResponse = PersistedGraph; diff --git a/packages/platform-server/src/teams/teamsGrpc.client.ts b/packages/platform-server/src/teams/teamsGrpc.client.ts index 978b1bb1b..b8ff7c195 100644 --- a/packages/platform-server/src/teams/teamsGrpc.client.ts +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -6,30 +6,36 @@ import { AgentCreateRequestSchema, AgentUpdateRequestSchema, AttachmentCreateRequestSchema, + CreateVariableRequestSchema, DeleteAgentRequestSchema, DeleteAttachmentRequestSchema, DeleteMcpServerRequestSchema, DeleteMemoryBucketRequestSchema, DeleteToolRequestSchema, + DeleteVariableRequestSchema, DeleteWorkspaceConfigurationRequestSchema, GetAgentRequestSchema, GetMcpServerRequestSchema, GetMemoryBucketRequestSchema, GetToolRequestSchema, + GetVariableRequestSchema, GetWorkspaceConfigurationRequestSchema, ListAgentsRequestSchema, ListAttachmentsRequestSchema, ListMcpServersRequestSchema, ListMemoryBucketsRequestSchema, ListToolsRequestSchema, + ListVariablesRequestSchema, ListWorkspaceConfigurationsRequestSchema, McpServerCreateRequestSchema, McpServerUpdateRequestSchema, MemoryBucketCreateRequestSchema, MemoryBucketUpdateRequestSchema, + ResolveVariableRequestSchema, TeamsService, ToolCreateRequestSchema, ToolUpdateRequestSchema, + UpdateVariableRequestSchema, WorkspaceConfigurationCreateRequestSchema, WorkspaceConfigurationUpdateRequestSchema, } from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; @@ -39,38 +45,66 @@ import type { AgentUpdateRequest, Attachment, AttachmentCreateRequest, + CreateAgentResponse, + CreateAttachmentResponse, + CreateMcpServerResponse, + CreateMemoryBucketResponse, + CreateToolResponse, + CreateVariableRequest, + CreateVariableResponse, + CreateWorkspaceConfigurationResponse, DeleteAgentRequest, DeleteAttachmentRequest, DeleteMcpServerRequest, DeleteMemoryBucketRequest, DeleteToolRequest, + DeleteVariableRequest, DeleteWorkspaceConfigurationRequest, GetAgentRequest, GetMcpServerRequest, GetMemoryBucketRequest, GetToolRequest, + GetVariableRequest, GetWorkspaceConfigurationRequest, + GetAgentResponse, + GetMcpServerResponse, + GetMemoryBucketResponse, + GetToolResponse, + GetVariableResponse, + GetWorkspaceConfigurationResponse, ListAgentsRequest, + ListAgentsResponse, ListAttachmentsRequest, + ListAttachmentsResponse, ListMcpServersRequest, + ListMcpServersResponse, ListMemoryBucketsRequest, + ListMemoryBucketsResponse, ListToolsRequest, + ListToolsResponse, + ListVariablesRequest, + ListVariablesResponse, ListWorkspaceConfigurationsRequest, + ListWorkspaceConfigurationsResponse, McpServer, McpServerCreateRequest, McpServerUpdateRequest, MemoryBucket, MemoryBucketCreateRequest, MemoryBucketUpdateRequest, - PaginatedAgents, - PaginatedAttachments, - PaginatedMcpServers, - PaginatedMemoryBuckets, - PaginatedTools, - PaginatedWorkspaceConfigurations, + ResolveVariableRequest, + ResolveVariableResponse, Tool, ToolCreateRequest, ToolUpdateRequest, + UpdateAgentResponse, + UpdateMcpServerResponse, + UpdateMemoryBucketResponse, + UpdateToolResponse, + UpdateVariableRequest, + UpdateVariableResponse, + UpdateWorkspaceConfigurationResponse, + Variable, WorkspaceConfiguration, WorkspaceConfigurationCreateRequest, WorkspaceConfigurationUpdateRequest, @@ -131,7 +165,7 @@ export class TeamsGrpcClient { return this.endpoint; } - async listAgents(request: ListAgentsRequest): Promise { + async listAgents(request: ListAgentsRequest): Promise { return this.call( teamsServicePath('listAgents'), ListAgentsRequestSchema, @@ -141,30 +175,33 @@ export class TeamsGrpcClient { } async createAgent(request: AgentCreateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('createAgent'), AgentCreateRequestSchema, request, 'createAgent', ); + return this.requireResponseField(response.agent, 'agent'); } async getAgent(request: GetAgentRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('getAgent'), GetAgentRequestSchema, request, 'getAgent', ); + return this.requireResponseField(response.agent, 'agent'); } async updateAgent(request: AgentUpdateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('updateAgent'), AgentUpdateRequestSchema, request, 'updateAgent', ); + return this.requireResponseField(response.agent, 'agent'); } async deleteAgent(request: DeleteAgentRequest): Promise { @@ -176,7 +213,7 @@ export class TeamsGrpcClient { ); } - async listTools(request: ListToolsRequest): Promise { + async listTools(request: ListToolsRequest): Promise { return this.call( teamsServicePath('listTools'), ListToolsRequestSchema, @@ -186,30 +223,33 @@ export class TeamsGrpcClient { } async createTool(request: ToolCreateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('createTool'), ToolCreateRequestSchema, request, 'createTool', ); + return this.requireResponseField(response.tool, 'tool'); } async getTool(request: GetToolRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('getTool'), GetToolRequestSchema, request, 'getTool', ); + return this.requireResponseField(response.tool, 'tool'); } async updateTool(request: ToolUpdateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('updateTool'), ToolUpdateRequestSchema, request, 'updateTool', ); + return this.requireResponseField(response.tool, 'tool'); } async deleteTool(request: DeleteToolRequest): Promise { @@ -221,7 +261,7 @@ export class TeamsGrpcClient { ); } - async listMcpServers(request: ListMcpServersRequest): Promise { + async listMcpServers(request: ListMcpServersRequest): Promise { return this.call( teamsServicePath('listMcpServers'), ListMcpServersRequestSchema, @@ -231,30 +271,33 @@ export class TeamsGrpcClient { } async createMcpServer(request: McpServerCreateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('createMcpServer'), McpServerCreateRequestSchema, request, 'createMcpServer', ); + return this.requireResponseField(response.mcpServer, 'mcp_server'); } async getMcpServer(request: GetMcpServerRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('getMcpServer'), GetMcpServerRequestSchema, request, 'getMcpServer', ); + return this.requireResponseField(response.mcpServer, 'mcp_server'); } async updateMcpServer(request: McpServerUpdateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('updateMcpServer'), McpServerUpdateRequestSchema, request, 'updateMcpServer', ); + return this.requireResponseField(response.mcpServer, 'mcp_server'); } async deleteMcpServer(request: DeleteMcpServerRequest): Promise { @@ -268,7 +311,7 @@ export class TeamsGrpcClient { async listWorkspaceConfigurations( request: ListWorkspaceConfigurationsRequest, - ): Promise { + ): Promise { return this.call( teamsServicePath('listWorkspaceConfigurations'), ListWorkspaceConfigurationsRequestSchema, @@ -280,34 +323,43 @@ export class TeamsGrpcClient { async createWorkspaceConfiguration( request: WorkspaceConfigurationCreateRequest, ): Promise { - return this.call( + const response = await this.call< + WorkspaceConfigurationCreateRequest, + CreateWorkspaceConfigurationResponse + >( teamsServicePath('createWorkspaceConfiguration'), WorkspaceConfigurationCreateRequestSchema, request, 'createWorkspaceConfiguration', ); + return this.requireResponseField(response.workspaceConfiguration, 'workspace_configuration'); } async getWorkspaceConfiguration( request: GetWorkspaceConfigurationRequest, ): Promise { - return this.call( + const response = await this.call( teamsServicePath('getWorkspaceConfiguration'), GetWorkspaceConfigurationRequestSchema, request, 'getWorkspaceConfiguration', ); + return this.requireResponseField(response.workspaceConfiguration, 'workspace_configuration'); } async updateWorkspaceConfiguration( request: WorkspaceConfigurationUpdateRequest, ): Promise { - return this.call( + const response = await this.call< + WorkspaceConfigurationUpdateRequest, + UpdateWorkspaceConfigurationResponse + >( teamsServicePath('updateWorkspaceConfiguration'), WorkspaceConfigurationUpdateRequestSchema, request, 'updateWorkspaceConfiguration', ); + return this.requireResponseField(response.workspaceConfiguration, 'workspace_configuration'); } async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { @@ -319,7 +371,7 @@ export class TeamsGrpcClient { ); } - async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { + async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { return this.call( teamsServicePath('listMemoryBuckets'), ListMemoryBucketsRequestSchema, @@ -329,30 +381,33 @@ export class TeamsGrpcClient { } async createMemoryBucket(request: MemoryBucketCreateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('createMemoryBucket'), MemoryBucketCreateRequestSchema, request, 'createMemoryBucket', ); + return this.requireResponseField(response.memoryBucket, 'memory_bucket'); } async getMemoryBucket(request: GetMemoryBucketRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('getMemoryBucket'), GetMemoryBucketRequestSchema, request, 'getMemoryBucket', ); + return this.requireResponseField(response.memoryBucket, 'memory_bucket'); } async updateMemoryBucket(request: MemoryBucketUpdateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('updateMemoryBucket'), MemoryBucketUpdateRequestSchema, request, 'updateMemoryBucket', ); + return this.requireResponseField(response.memoryBucket, 'memory_bucket'); } async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { @@ -364,7 +419,64 @@ export class TeamsGrpcClient { ); } - async listAttachments(request: ListAttachmentsRequest): Promise { + async listVariables(request: ListVariablesRequest): Promise { + return this.call( + teamsServicePath('listVariables'), + ListVariablesRequestSchema, + request, + 'listVariables', + ); + } + + async createVariable(request: CreateVariableRequest): Promise { + const response = await this.call( + teamsServicePath('createVariable'), + CreateVariableRequestSchema, + request, + 'createVariable', + ); + return this.requireResponseField(response.variable, 'variable'); + } + + async getVariable(request: GetVariableRequest): Promise { + const response = await this.call( + teamsServicePath('getVariable'), + GetVariableRequestSchema, + request, + 'getVariable', + ); + return this.requireResponseField(response.variable, 'variable'); + } + + async updateVariable(request: UpdateVariableRequest): Promise { + const response = await this.call( + teamsServicePath('updateVariable'), + UpdateVariableRequestSchema, + request, + 'updateVariable', + ); + return this.requireResponseField(response.variable, 'variable'); + } + + async deleteVariable(request: DeleteVariableRequest): Promise { + await this.call( + teamsServicePath('deleteVariable'), + DeleteVariableRequestSchema, + request, + 'deleteVariable', + ); + } + + async resolveVariable(request: ResolveVariableRequest): Promise { + return this.call( + teamsServicePath('resolveVariable'), + ResolveVariableRequestSchema, + request, + 'resolveVariable', + ); + } + + async listAttachments(request: ListAttachmentsRequest): Promise { return this.call( teamsServicePath('listAttachments'), ListAttachmentsRequestSchema, @@ -374,12 +486,13 @@ export class TeamsGrpcClient { } async createAttachment(request: AttachmentCreateRequest): Promise { - return this.call( + const response = await this.call( teamsServicePath('createAttachment'), AttachmentCreateRequestSchema, request, 'createAttachment', ); + return this.requireResponseField(response.attachment, 'attachment'); } async deleteAttachment(request: DeleteAttachmentRequest): Promise { @@ -391,6 +504,13 @@ export class TeamsGrpcClient { ); } + private requireResponseField(value: T | undefined, label: string): T { + if (value === undefined || value === null) { + throw new Error(`teams_missing_${label}`); + } + return value; + } + private call( path: string, schema: DescMessage, diff --git a/packages/platform-server/src/teams/teamsGrpc.pagination.ts b/packages/platform-server/src/teams/teamsGrpc.pagination.ts index a7410e124..22df4bbc9 100644 --- a/packages/platform-server/src/teams/teamsGrpc.pagination.ts +++ b/packages/platform-server/src/teams/teamsGrpc.pagination.ts @@ -3,29 +3,25 @@ export const MAX_PAGES = 50; export type PaginatedResponse = { items: T[]; - page: number; - perPage: number; - total: bigint; + nextPageToken?: string | null; }; export const listAllPages = async ( - fetchPage: (page: number, perPage: number) => Promise>, + fetchPage: (pageToken: string | undefined, pageSize: number) => Promise>, ): Promise => { const items: T[] = []; - let page = 1; + let pageToken: string | undefined = undefined; for (let i = 0; i < MAX_PAGES; i += 1) { - const response = await fetchPage(page, DEFAULT_PAGE_SIZE); + const response = await fetchPage(pageToken, DEFAULT_PAGE_SIZE); const pageItems = response.items; items.push(...pageItems); - const perPage = response.perPage; - if (perPage === 0) { - throw new Error('teams_pagination_per_page_zero'); + const nextToken = readString(response.nextPageToken ?? undefined); + if (!nextToken) break; + if (nextToken === pageToken) { + throw new Error('teams_pagination_duplicate_token'); } - const total = Number(response.total); - const reachedEnd = response.page * perPage >= total; - if (reachedEnd) break; if (pageItems.length === 0) break; - page = response.page + 1; + pageToken = nextToken; } return items; }; diff --git a/packages/platform-server/src/utils/reference-resolver.service.ts b/packages/platform-server/src/utils/reference-resolver.service.ts index d1412ab05..dd9b33b21 100644 --- a/packages/platform-server/src/utils/reference-resolver.service.ts +++ b/packages/platform-server/src/utils/reference-resolver.service.ts @@ -1,25 +1,17 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; import type { ResolveOptions, ResolveResult, Providers } from './references'; import { resolveReferences, ResolveError } from './references'; import { VaultService } from '../vault/vault.service'; -import { GraphVariablesService } from '../graph/services/graphVariables.service'; +import { TEAMS_GRPC_CLIENT } from '../teams/teamsGrpc.token'; +import type { TeamsGrpcClient } from '../teams/teamsGrpc.client'; @Injectable() export class ReferenceResolverService { constructor( - @Inject(ModuleRef) private readonly moduleRef: ModuleRef, @Inject(VaultService) private readonly vaultService: VaultService, + @Inject(TEAMS_GRPC_CLIENT) private readonly teamsClient: TeamsGrpcClient, ) {} - private getVariablesService(): GraphVariablesService | undefined { - try { - return this.moduleRef.get(GraphVariablesService, { strict: false }); - } catch { - return undefined; - } - } - private buildProviders( graphName: string | undefined, overrides: Partial | undefined, @@ -35,14 +27,16 @@ export class ReferenceResolverService { return this.vaultService.getSecret({ mount: ref.mount ?? 'secret', path: ref.path, key: ref.key }); }; const variable = async (ref: { name: string }) => { - const variablesService = this.getVariablesService(); - if (!variablesService) { - throw new ResolveError('provider_missing', 'GraphVariablesService unavailable', { + if (!this.teamsClient) { + throw new ResolveError('provider_missing', 'TeamsGrpcClient unavailable', { path: basePath ?? '/variable', source: 'variable', }); } - return variablesService.resolveValue(graphName ?? 'main', ref.name); + const response = await this.teamsClient.resolveVariable({ key: ref.name }); + if (!response?.found) return undefined; + const value = response.value?.trim?.() ?? response.value; + return value && value.length > 0 ? value : undefined; }; return { secret, diff --git a/packages/platform-ui/__tests__/components/configViews/McpServerDynamicConfigView.test.tsx b/packages/platform-ui/__tests__/components/configViews/McpServerDynamicConfigView.test.tsx deleted file mode 100644 index b960ca006..000000000 --- a/packages/platform-ui/__tests__/components/configViews/McpServerDynamicConfigView.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import React from 'react'; -import McpServerDynamicConfigView from '@/components/configViews/McpServerDynamicConfigView'; - -const g: any = globalThis; - -vi.mock('@/lib/graph/hooks', () => { - return { - useMcpNodeState: (nodeId: string) => { - const [enabledTools, setEnabledToolsState] = React.useState(undefined); - const tools = [ - { name: 't1', description: 'Tool 1' }, - { name: 't2', description: 'Tool 2' }, - ]; - return { - tools, - enabledTools, - isLoading: false, - setEnabledTools: (next: string[]) => { - setEnabledToolsState(next); - // simulate API call for assertion - fetch(`/api/graph/nodes/${nodeId}/state`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ state: { mcp: { enabledTools: next } } }), - }); - }, - } as const; - }, - }; -}); - -describe('MCP tools management via node state', () => { - const origFetch = g.fetch; - const nodeId = 'n1'; - - beforeEach(() => { - g.fetch = vi.fn(async (input: RequestInfo, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as Request).url; - if (url.includes(`/api/graph/nodes/${nodeId}/state`) && init?.method === 'PUT') { - const body = init.body ? JSON.parse(String(init.body)) : {}; - expect(body).toEqual({ state: { mcp: { enabledTools: ['t1'] } } }); - return new Response(JSON.stringify({ ok: true })); - } - return new Response('', { status: 204 }); - }) as any; - }); - afterEach(() => { - g.fetch = origFetch; - }); - - it('renders tools from state and derives enabled when enabledTools undefined', () => { - render( {}} />); - expect(screen.getByTestId('tool-t1')).toBeInTheDocument(); - const c1 = screen.getByRole('checkbox', { name: /t1/ }) as HTMLInputElement; - const c2 = screen.getByRole('checkbox', { name: /t2/ }) as HTMLInputElement; - // All enabled by default when enabledTools is undefined - expect(c1.checked).toBe(true); - expect(c2.checked).toBe(true); - }); - - it('toggle writes enabledTools and updates UI', async () => { - render( {}} />); - expect(screen.getByTestId('tool-t1')).toBeInTheDocument(); - const c1 = screen.getByRole('checkbox', { name: /t1/ }) as HTMLInputElement; - // Disable t2 -> PUT with ['t1'] - const c2 = screen.getByRole('checkbox', { name: /t2/ }) as HTMLInputElement; - fireEvent.click(c2); - await waitFor(() => expect(g.fetch).toHaveBeenCalled()); - await waitFor(() => expect(c2.checked).toBe(false)); - expect(c1.checked).toBe(true); - }); -}); diff --git a/packages/platform-ui/__tests__/integration/testUtils.tsx b/packages/platform-ui/__tests__/integration/testUtils.tsx index 91abf13fd..7cc298037 100644 --- a/packages/platform-ui/__tests__/integration/testUtils.tsx +++ b/packages/platform-ui/__tests__/integration/testUtils.tsx @@ -126,7 +126,6 @@ const relativeHandlers = [ const nodeId = params.nodeId as string; return _HttpResponse.json({ nodeId, - isPaused: false, provisionStatus: { state: 'not_ready' }, // dynamicConfigReady removed }); @@ -248,7 +247,6 @@ const absoluteHandlers = [ const nodeId = params.nodeId as string; return _HttpResponse.json({ nodeId, - isPaused: false, provisionStatus: { state: 'not_ready' }, }); }), diff --git a/packages/platform-ui/src/api/hooks/graph.ts b/packages/platform-ui/src/api/hooks/graph.ts index 90d4e5907..38280ed49 100644 --- a/packages/platform-ui/src/api/hooks/graph.ts +++ b/packages/platform-ui/src/api/hooks/graph.ts @@ -7,6 +7,5 @@ export { useNodeAction, useDynamicConfig, useSaveGraph, - useMcpNodeState, + useMcpTools, } from '@/lib/graph/hooks'; - diff --git a/packages/platform-ui/src/api/modules/graph.ts b/packages/platform-ui/src/api/modules/graph.ts index 53c836665..118f40c1f 100644 --- a/packages/platform-ui/src/api/modules/graph.ts +++ b/packages/platform-ui/src/api/modules/graph.ts @@ -52,11 +52,12 @@ export const graph = { writeVaultKey: (mount: string, body: { path: string; key: string; value: string }) => http.post<{ mount: string; path: string; key: string; version: number }>(`/api/vault/kv/${encodeURIComponent(mount)}/write`, body), - // Node status/state + // Node status/tools getNodeStatus: (nodeId: string) => http.get(`/api/graph/nodes/${encodeURIComponent(nodeId)}/status`), - getNodeState: (nodeId: string) => http.get<{ state: Record }>(`/api/graph/nodes/${encodeURIComponent(nodeId)}/state`), - putNodeState: (nodeId: string, state: Record) => - http.put<{ state: Record }>(`/api/graph/nodes/${encodeURIComponent(nodeId)}/state`, { state }), + discoverNodeTools: (nodeId: string) => + http.post<{ tools: Array<{ name: string; description: string }>; updatedAt?: string }>( + `/api/graph/nodes/${encodeURIComponent(nodeId)}/discover-tools`, + ), // Dynamic config schema (404 -> null) getDynamicConfigSchema: async (nodeId: string): Promise | null> => { diff --git a/packages/platform-ui/src/api/types/graph.ts b/packages/platform-ui/src/api/types/graph.ts index 1791267e8..b546bf633 100644 --- a/packages/platform-ui/src/api/types/graph.ts +++ b/packages/platform-ui/src/api/types/graph.ts @@ -25,7 +25,7 @@ export interface TemplateSchema { } export interface ProvisionStatus { state: ProvisionState; details?: unknown } -export interface NodeStatus { isPaused?: boolean; provisionStatus?: ProvisionStatus } +export interface NodeStatus { provisionStatus?: ProvisionStatus } export interface ReminderDTO { id: string; threadId: string; note: string; at: string } diff --git a/packages/platform-ui/src/components/ToolItem.tsx b/packages/platform-ui/src/components/ToolItem.tsx index 962f389b5..66a6abb00 100644 --- a/packages/platform-ui/src/components/ToolItem.tsx +++ b/packages/platform-ui/src/components/ToolItem.tsx @@ -1,17 +1,18 @@ -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect, useMemo, useState } from 'react'; import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; -import { Toggle } from './Toggle'; interface ToolItemProps { name: string; - description: string; - enabled: boolean; - onToggle: (enabled: boolean) => void; + description?: string; } -export function ToolItem({ name, description, enabled, onToggle }: ToolItemProps) { +export function ToolItem({ name, description }: ToolItemProps) { const nameRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); + const trimmedDescription = useMemo(() => { + const value = typeof description === 'string' ? description.trim() : ''; + return value.length > 0 ? value : null; + }, [description]); useEffect(() => { const checkTruncation = () => { @@ -35,8 +36,8 @@ export function ToolItem({ name, description, enabled, onToggle }: ToolItemProps ); return ( -
-
+
+
{isTruncated ? ( @@ -49,17 +50,11 @@ export function ToolItem({ name, description, enabled, onToggle }: ToolItemProps ) : ( nameElement )} -
- {description} -
-
-
- + {trimmedDescription && ( +
+ {trimmedDescription} +
+ )}
); diff --git a/packages/platform-ui/src/components/agents/GraphLayout.tsx b/packages/platform-ui/src/components/agents/GraphLayout.tsx index 0a94571e7..01845aa6b 100644 --- a/packages/platform-ui/src/components/agents/GraphLayout.tsx +++ b/packages/platform-ui/src/components/agents/GraphLayout.tsx @@ -13,21 +13,19 @@ import { GraphCanvas, type GraphCanvasDropHandler, type GraphNodeData } from '.. import { GradientEdge } from './edges/GradientEdge'; import EmptySelectionSidebar from '../EmptySelectionSidebar'; import NodePropertiesSidebar, { type NodeConfig as SidebarNodeConfig } from '../NodePropertiesSidebar'; -import type { McpToolDescriptor } from '../nodeProperties/types'; import { resolveAgentDisplayTitle } from '../../utils/agentDisplay'; import { useGraphData } from '@/features/graph/hooks/useGraphData'; import { useGraphSocket } from '@/features/graph/hooks/useGraphSocket'; import { useNodeStatus } from '@/features/graph/hooks/useNodeStatus'; import { useNodeAction } from '@/features/graph/hooks/useNodeAction'; -import { useMcpNodeState, useTemplates } from '@/lib/graph/hooks'; +import { useMcpTools, useTemplates } from '@/lib/graph/hooks'; import { mapTemplatesToSidebarItems } from '@/lib/graph/sidebarNodeItems'; import { buildGraphNodeFromTemplate } from '@/features/graph/mappers'; import type { GraphNodeConfig, GraphNodeStatus, GraphPersistedEdge } from '@/features/graph/types'; import type { TemplateSchema, NodeStatus as ApiNodeStatus } from '@/api/types/graph'; import { listAllSecretPaths } from '@/features/secrets/utils/flatVault'; import { getUuid } from '@/utils/getUuid'; -import { isRecord } from '@/utils/typeGuards'; type FlowNode = Node; @@ -120,42 +118,6 @@ function encodeHandle(handle?: string | null): string { return '$'; } -type McpStateSnapshot = { - tools: McpToolDescriptor[]; - enabledTools?: string[]; -}; - -function parseMcpTool(value: unknown): McpToolDescriptor | null { - if (!isRecord(value)) return null; - const name = typeof value.name === 'string' ? value.name.trim() : ''; - if (!name) return null; - const tool: McpToolDescriptor = { name }; - if (typeof value.title === 'string') { - tool.title = value.title; - } - if (typeof value.description === 'string') { - tool.description = value.description; - } - return tool; -} - -function readMcpState(state: unknown): McpStateSnapshot { - if (!isRecord(state)) { - return { tools: [], enabledTools: undefined }; - } - const mcp = isRecord(state.mcp) ? (state.mcp as Record) : null; - if (!mcp) { - return { tools: [], enabledTools: undefined }; - } - const tools = Array.isArray(mcp.tools) - ? mcp.tools.map(parseMcpTool).filter((tool): tool is McpToolDescriptor => tool !== null) - : []; - const enabledTools = Array.isArray(mcp.enabledTools) - ? mcp.enabledTools.filter((tool): tool is string => typeof tool === 'string') - : undefined; - return { tools, enabledTools }; -} - function decodeHandle(handle?: string | null): string | undefined { if (!handle || handle === '$') { return undefined; @@ -244,7 +206,6 @@ export function GraphLayout({ services }: GraphLayoutProps) { savingErrorMessage, updateNode, applyNodeStatus, - applyNodeState, setEdges, removeNodes, addNode, @@ -429,9 +390,6 @@ export function GraphLayout({ services }: GraphLayoutProps) { const { nodeId, updatedAt: _ignored, ...status } = event; applyNodeStatus(nodeId, status); }, - onState: (event) => { - applyNodeState(event.nodeId, event.state ?? {}); - }, }); const [flowNodes, setFlowNodes] = useState([]); @@ -627,33 +585,15 @@ export function GraphLayout({ services }: GraphLayoutProps) { const mcpNodeId = selectedNode?.kind === 'MCP' ? selectedNode.id : null; const { tools: mcpTools, - enabledTools: mcpEnabledTools, - setEnabledTools: setMcpEnabledTools, + updatedAt: mcpToolsUpdatedAt, + discoverTools: discoverMcpTools, isLoading: mcpToolsLoading, - } = useMcpNodeState(mcpNodeId); - const fallbackMcpState = useMemo(() => { - if (!mcpNodeId) { - return { tools: [], enabledTools: undefined }; - } - return readMcpState(selectedNode?.state); - }, [mcpNodeId, selectedNode?.state]); - const resolvedMcpTools = mcpToolsLoading ? fallbackMcpState.tools : mcpTools; - const resolvedEnabledTools = mcpEnabledTools ?? (mcpToolsLoading ? fallbackMcpState.enabledTools : undefined); - - const handleToggleMcpTool = useCallback( - (toolName: string, enabled: boolean) => { - if (!mcpNodeId) return; - const current = resolvedEnabledTools ?? []; - const next = new Set(current); - if (enabled) { - next.add(toolName); - } else { - next.delete(toolName); - } - setMcpEnabledTools(Array.from(next)); - }, - [mcpNodeId, resolvedEnabledTools, setMcpEnabledTools], - ); + } = useMcpTools(mcpNodeId); + + const handleDiscoverMcpTools = useCallback(() => { + if (!mcpNodeId) return; + void discoverMcpTools(); + }, [discoverMcpTools, mcpNodeId]); const handleNodesChange = useCallback((changes: Parameters[0]) => { let nextSelectedId = selectedNodeIdRef.current; @@ -920,9 +860,9 @@ export function GraphLayout({ services }: GraphLayoutProps) { canProvision={canProvision} canDeprovision={canDeprovision} isActionPending={isActionPending} - tools={resolvedMcpTools} - enabledTools={resolvedEnabledTools} - onToggleTool={handleToggleMcpTool} + tools={mcpTools} + toolsUpdatedAt={mcpToolsUpdatedAt} + onDiscoverTools={handleDiscoverMcpTools} toolsLoading={mcpToolsLoading} nixPackageSearch={handleNixPackageSearch} fetchNixPackageVersions={handleFetchNixPackageVersions} diff --git a/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx b/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx index cd89a31da..40937ab72 100644 --- a/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx +++ b/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx @@ -23,7 +23,7 @@ import type { TemplateOption, } from '@/features/entities/types'; import type { GraphNodeConfig, GraphPersistedEdge } from '@/features/graph/types'; -import { useMcpNodeState } from '@/lib/graph/hooks'; +import { useMcpTools } from '@/lib/graph/hooks'; import { EXCLUDED_WORKSPACE_TEMPLATES, TEAM_ATTACHMENT_KIND, @@ -540,24 +540,15 @@ export function EntityUpsertForm({ const mcpStateNodeId = nodeKind === 'MCP' && mode === 'edit' ? entity?.id ?? null : null; const { tools: mcpTools, - enabledTools: mcpEnabledTools, - setEnabledTools: setMcpEnabledTools, + updatedAt: mcpToolsUpdatedAt, + discoverTools: discoverMcpTools, isLoading: mcpToolsLoading, - } = useMcpNodeState(mcpStateNodeId); - - const handleToggleMcpTool = useCallback( - (toolName: string, enabled: boolean) => { - if (!mcpStateNodeId) return; - const current = new Set(mcpEnabledTools ?? []); - if (enabled) { - current.add(toolName); - } else { - current.delete(toolName); - } - setMcpEnabledTools(Array.from(current)); - }, - [mcpEnabledTools, mcpStateNodeId, setMcpEnabledTools], - ); + } = useMcpTools(mcpStateNodeId); + + const handleDiscoverMcpTools = useCallback(() => { + if (!mcpStateNodeId) return; + void discoverMcpTools(); + }, [discoverMcpTools, mcpStateNodeId]); const handleViewConfigChange = useCallback( (partial: Partial) => { @@ -683,8 +674,8 @@ export function EntityUpsertForm({ ensureSecretKeys, ensureVariableKeys, tools: mcpTools, - enabledTools: mcpEnabledTools, - onToggleTool: handleToggleMcpTool, + toolsUpdatedAt: mcpToolsUpdatedAt, + onDiscoverTools: handleDiscoverMcpTools, toolsLoading: mcpToolsLoading, nodeId: nodeIdForView, graphNodes: safeGraphNodes, @@ -766,8 +757,8 @@ export function EntityUpsertForm({ safeGraphNodes, safeGraphEdges, mcpTools, - mcpEnabledTools, - handleToggleMcpTool, + mcpToolsUpdatedAt, + handleDiscoverMcpTools, mcpToolsLoading, ]); diff --git a/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx b/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx index ce9be1add..52dc242cd 100644 --- a/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx +++ b/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx @@ -1,12 +1,11 @@ import { Badge } from '@/components/Badge'; import type { ProvisionState } from '@/api/types/graph'; -import { badgeVariantForColor, badgeVariantForState, isFailedProvisionState } from '../entities/provisionStatusDisplay'; +import { badgeVariantForState, isFailedProvisionState } from '../entities/provisionStatusDisplay'; -export function NodeStatusBadges({ state, isPaused, detail }: { state: ProvisionState | string; isPaused: boolean; detail: unknown }) { +export function NodeStatusBadges({ state, detail }: { state: ProvisionState | string; detail: unknown }) { return (
{state} - {isPaused && paused} {isFailedProvisionState(state) && detail ? ( details diff --git a/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx b/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx index 49b833c1a..9ecb7465c 100644 --- a/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx +++ b/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx @@ -6,7 +6,7 @@ import NodeDetailsPanel from '../NodeDetailsPanel'; // No templates provider mock needed; actions and badges are driven by status alone -let mockStatus: any = { isPaused: false, provisionStatus: { state: 'not_ready' } }; +let mockStatus: any = { provisionStatus: { state: 'not_ready' } }; let mockMutate = vi.fn(); vi.mock('../../../lib/graph/hooks', () => ({ @@ -16,7 +16,7 @@ vi.mock('../../../lib/graph/hooks', () => ({ describe('NodeDetailsPanel', () => { beforeEach(() => { - mockStatus = { isPaused: false, provisionStatus: { state: 'not_ready' } }; + mockStatus = { provisionStatus: { state: 'not_ready' } }; mockMutate = vi.fn(); }); @@ -37,7 +37,7 @@ describe('NodeDetailsPanel', () => { }); it('enables Provision on not_ready and calls provision', () => { - mockStatus = { isPaused: false, provisionStatus: { state: 'not_ready' } }; + mockStatus = { provisionStatus: { state: 'not_ready' } }; renderPanel(); const start = screen.getByText('Provision'); expect(start).not.toBeDisabled(); @@ -48,7 +48,7 @@ describe('NodeDetailsPanel', () => { // Pause/Resume removed; Start/Stop only per server API alignment it('enables Deprovision when ready', () => { - mockStatus = { isPaused: false, provisionStatus: { state: 'ready' } }; + mockStatus = { provisionStatus: { state: 'ready' } }; renderPanel(); const stop = screen.getByText('Deprovision'); expect(stop).not.toBeDisabled(); diff --git a/packages/platform-ui/src/components/nodeProperties/McpSection.tsx b/packages/platform-ui/src/components/nodeProperties/McpSection.tsx index 3567d83ee..4a3319684 100644 --- a/packages/platform-ui/src/components/nodeProperties/McpSection.tsx +++ b/packages/platform-ui/src/components/nodeProperties/McpSection.tsx @@ -1,5 +1,7 @@ import { Input } from '../Input'; import { BashInput } from '../BashInput'; +import { Dropdown } from '../Dropdown'; +import { Textarea } from '../Textarea'; import type { EnvEditorProps } from './EnvEditor'; import { EnvEditor } from './EnvEditor'; @@ -30,11 +32,17 @@ interface McpSectionProps { onLimitsOpenChange: (open: boolean) => void; limits: McpLimits; onLimitChange: (key: keyof McpLimits, value: number | undefined) => void; + toolFilter: { + mode: 'allow' | 'deny'; + matchers: string; + onModeChange: (value: 'allow' | 'deny') => void; + onMatchersChange: (value: string) => void; + }; tools: { items: McpToolDescriptor[]; - enabled: Set; loading: boolean; - onToggle: (toolName: string, enabled: boolean) => void; + updatedAt?: string; + onDiscover?: () => void; }; } @@ -50,6 +58,7 @@ export function McpSection({ onLimitsOpenChange, limits, onLimitChange, + toolFilter, tools, }: McpSectionProps) { const limitFields: LimitField[] = [ @@ -142,11 +151,39 @@ export function McpSection({ +
+
+ toolFilter.onModeChange(value === 'deny' ? 'deny' : 'allow')} + options={[ + { value: 'allow', label: 'Allow list' }, + { value: 'deny', label: 'Deny list' }, + ]} + size="sm" + /> +
+ +