diff --git a/README.md b/README.md index 42857b53c..14b8188fe 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,9 @@ Key environment variables (server) from packages/platform-server/.env.example an - LITELLM_MASTER_KEY — admin key for LiteLLM - Optional LLM: - OPENAI_API_KEY, OPENAI_BASE_URL -- Graph store: - - GRAPH_REPO_PATH (default ./data/graph) +- Graph metadata: - GRAPH_BRANCH (default main) - GRAPH_AUTHOR_NAME, GRAPH_AUTHOR_EMAIL (deprecated; retained for compatibility) - - GRAPH_LOCK_TIMEOUT_MS (default 5000) - Vault: - VAULT_ENABLED (default false), VAULT_ADDR (default http://localhost:8200), VAULT_TOKEN (default dev-root) - Workspace/Docker: @@ -289,7 +287,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, /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 @@ -346,5 +344,4 @@ Secrets handling: - Production Vault: dev auto-init script (vault/auto-init.sh) is not suitable; confirm production secret management approach and policies. - UI Storybook deployment: CI builds and smoke-tests Storybook, but no public hosting config is present. Confirm desired publishing workflow. - NCPS in production: ops/k8s manifests are examples; confirm production deployment/monitoring design. -- Filesystem-backed graph store (GRAPH_REPO_PATH=./data/graph, GRAPH_BRANCH=main) assumes the path is writable and durable. Confirm persistence strategy in production (persistent volumes/NFS) and keep legacy git repos out of the configured path; the server now reads/writes directly to the working tree without migrations. - Confirm whether the general postgres service (5442) is used by other components or is purely for convenience; server uses agents-db (5443). diff --git a/buf.gen.yaml b/buf.gen.yaml index 5a8a9e5e2..661c871eb 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -8,3 +8,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/docs/README.md b/docs/README.md index c2b19c739..8d1784abb 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. @@ -12,8 +12,8 @@ Index - API Reference: [api/index.md](api/index.md) - Realtime notifications: socket payloads are published via the notifications service and served by notifications-gateway when running docker-compose.yml. - 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) @@ -37,4 +37,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/api/index.md b/docs/api/index.md index 57206a65b..ba2ef20d7 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,47 +14,21 @@ Templates curl http://localhost:3010/api/templates ``` -Graph state (filesystem-backed) -- GET `/api/graph` - - 200: Persisted graph document: `{ name, version, updatedAt, nodes, edges }` - - Example: - ```bash - curl http://localhost:3010/api/graph - ``` -- POST `/api/graph` - - Body: `PersistedGraphUpsertRequest` → `{ name='main', version, nodes, edges }` - - Headers (optional): `x-graph-author-name`, `x-graph-author-email` are retained for compatibility but no longer influence persistence. - - Success: returns updated persisted graph `{ name, version, updatedAt, nodes, edges }` - - Errors (status → body): - - 409 `{ error: 'VERSION_CONFLICT', current?: PersistedGraph }` - - 409 `{ error: 'LOCK_TIMEOUT' }` - - 409 `{ error: 'MCP_COMMAND_MUTATION_FORBIDDEN' }` (enum value GraphErrorCode.McpCommandMutationForbidden) - - 500 `{ error: 'PERSIST_FAILED' }` - - 400 `{ error: 'Bad Request' | string }` (includes deterministic edge check; see notes) - - Notes: - - A provided `edge.id` must match the deterministic id `${source}-${sourceHandle}__${target}-${targetHandle}`. If it doesn't, the server returns `400` with `{ error: 'Edge id mismatch: expected got ' }`. - - Persistence failures surface as `500 { error: 'PERSIST_FAILED' }`. - - Lock acquisition timeout surfaces as `409 { error: 'LOCK_TIMEOUT' }`. - - Example: - ```bash - curl -X POST http://localhost:3010/api/graph \ - -H 'content-type: application/json' \ - -H 'x-graph-author-name: Jane Dev' \ - -H 'x-graph-author-email: jane@example.com' \ - -d '{"name":"main","version":1,"nodes":[],"edges":[]}' - ``` - Templates alias - GET `/graph/templates` → same as `/api/templates` 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 +49,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 +61,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; the platform no longer exposes `/api/graph`. - MCP mutation guard prevents unsafe changes to MCP commands. - Error codes align with the error envelope described above. Nix proxy 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..527b6663a 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` has been removed. 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..f0bbb62ca 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,38 +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 configuration updates. +- HTTP endpoints remain for actions (provision/deprovision). - 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). -- The per-node dynamic-config save endpoint was removed; only the schema endpoint remains for rendering purposes. +Graph source and persistence +- Graph configuration is sourced from the Teams service; the platform no longer exposes a `/api/graph` snapshot endpoint. +- UI edits to layout are local-only; the backend does not accept full-graph writes. +- 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). -- `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. +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 5d11fe11e..02088941f 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. - 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,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 and variables are sourced from the Teams service via gRPC; 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 +- 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_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/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/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/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/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/__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/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/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/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/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/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 new file mode 100644 index 000000000..191fb6665 --- /dev/null +++ b/packages/docker-runner/src/service/grpc/server.ts @@ -0,0 +1,1270 @@ +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 '../../index.js'; +import type { ContainerHandle } from '../../lib/container.handle'; +import { verifyAuthHeaders } from '../../index.js'; +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 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}`; + +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; + logExec('closeResponses', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + }); + 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; + logExec('finish', { + executionId: target.executionId, + requestId: target.requestId, + reason, + killed, + }); + 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; + } + logExec('handleRequests completed', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); + if (!ctx || closed) { + closeResponses(); + return; + } + if (ctx.finished) { + return; + } + return; + } catch { + 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); + 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 { + logExec('response loop closing', { + executionId: ctx?.executionId, + requestId: ctx?.requestId, + finished: ctx?.finished, + closed, + }); + 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..9d2f8a2f2 --- /dev/null +++ b/packages/docker-runner/src/service/main.ts @@ -0,0 +1,64 @@ +import './env'; + +import { ContainerService, NonceCache } from '../index.js'; +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/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"] +} diff --git a/packages/platform-server/.env.example b/packages/platform-server/.env.example index 7007ca825..5d115c10e 100644 --- a/packages/platform-server/.env.example +++ b/packages/platform-server/.env.example @@ -17,11 +17,12 @@ LLM_PROVIDER= LITELLM_BASE_URL=http://127.0.0.1:4000 LITELLM_MASTER_KEY=sk-dev-master-1234 -# Filesystem-backed graph persistence -GRAPH_REPO_PATH=./data/graph -# Optional logical branch label (metadata only) +# Graph metadata (legacy) # 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/DEVSPACE.md b/packages/platform-server/DEVSPACE.md index 638c106b3..165636ed9 100644 --- a/packages/platform-server/DEVSPACE.md +++ b/packages/platform-server/DEVSPACE.md @@ -27,9 +27,7 @@ and then attaches sync/ports/logs: creates subdirectories as root-owned `755`) and the startup script installs dependencies, generates protobuf and Prisma clients, and launches the dev server. -5. The dev pod overrides `GRAPH_REPO_PATH` to `/opt/app/data/graph` so the - graph repository lives on the writable emptyDir mount. -6. On exit, the ArgoCD hook restores auto-sync for `platform-server`. +5. On exit, the ArgoCD hook restores auto-sync for `platform-server`. Port `3010` is forwarded locally, so the API should be reachable at `http://localhost:3010` once the server reports ready. 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/__e2e__/app.bootstrap.smoke.test.ts b/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts index 17badd536..1740c0eef 100644 --- a/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts +++ b/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts @@ -20,12 +20,13 @@ import { VaultService } from '../src/vault/vault.service'; import { AgentNode } from '../src/nodes/agent/agent.node'; import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import { GraphRepository } from '../src/graph/graph.repository'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { ConfigService, configSchema } from '../src/core/services/config.service'; import { LLMSettingsService } from '../src/settings/llm/llmSettings.service'; import { runnerConfigDefaults } from '../__tests__/helpers/config'; +import { createTeamsClientStub } from '../__tests__/helpers/teamsGrpc.stub'; import { DOCKER_CLIENT, type DockerClient } from '../src/infra/container/dockerClient.token'; import { DockerWorkspaceEventsWatcher } from '../src/infra/container/containerEvent.watcher'; import { VolumeGcService } from '../src/infra/container/volumeGc.job'; @@ -53,7 +54,7 @@ type BootstrapStubs = { agentsPersistenceStub: Partial; threadsMetricsStub: Partial; vaultStub: Partial; - graphRepositoryStub: Record; + teamsGraphSourceStub: TeamsGraphSource; templateRegistryStub: TemplateRegistry; liveGraphRuntimeStub: LiveGraphRuntime; llmProvisionerStub: LLMProvisioner; @@ -141,19 +142,8 @@ const createBootstrapStubs = (): BootstrapStubs => { listKvV2Mounts: vi.fn().mockResolvedValue([]), } satisfies Partial; - const graphRepositoryStub = { - initIfNeeded: vi.fn().mockResolvedValue(undefined), - get: vi.fn().mockResolvedValue(null), - upsert: vi.fn().mockResolvedValue({ - name: 'main', - version: 0, - updatedAt: new Date(0).toISOString(), - nodes: [], - edges: [], - variables: [], - }), - upsertNodeState: vi.fn().mockResolvedValue(undefined), - } satisfies Record; + const teamsGraphSourceStub = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(teamsGraphSourceStub, 'load').mockResolvedValue({ nodes: [], edges: [] }); const templateRegistryStub = { register: vi.fn().mockReturnThis(), @@ -200,7 +190,7 @@ const createBootstrapStubs = (): BootstrapStubs => { agentsPersistenceStub, threadsMetricsStub, vaultStub, - graphRepositoryStub, + teamsGraphSourceStub, templateRegistryStub, liveGraphRuntimeStub, llmProvisionerStub, @@ -236,8 +226,8 @@ const applyBootstrapOverrides = ( .useValue(stubs.threadsMetricsStub as ThreadsMetricsService) .overrideProvider(VaultService) .useValue(stubs.vaultStub as VaultService) - .overrideProvider(GraphRepository) - .useValue(stubs.graphRepositoryStub as unknown as GraphRepository) + .overrideProvider(TeamsGraphSource) + .useValue(stubs.teamsGraphSourceStub) .overrideProvider(TemplateRegistry) .useValue(stubs.templateRegistryStub) .overrideProvider(LiveGraphRuntime) diff --git a/packages/platform-server/__e2e__/bootstrap.di.test.ts b/packages/platform-server/__e2e__/bootstrap.di.test.ts index 76dae0e74..0292c3db1 100644 --- a/packages/platform-server/__e2e__/bootstrap.di.test.ts +++ b/packages/platform-server/__e2e__/bootstrap.di.test.ts @@ -2,10 +2,7 @@ import 'reflect-metadata'; import { FastifyAdapter } from '@nestjs/platform-fastify'; import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; import { NestFactory } from '@nestjs/core'; -import { mkdtempSync, rmSync } from 'node:fs'; import { createServer, type IncomingMessage, type Server } from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; import type { PrismaClient } from '@prisma/client'; import { AppModule } from '../src/bootstrap/app.module'; @@ -24,6 +21,7 @@ const REQUIRED_ENV = { LITELLM_MASTER_KEY: 'sk-test-master-key', AGENTS_DATABASE_URL: process.env.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', @@ -37,13 +35,11 @@ const TEST_TIMEOUT_MS = 20_000; describe('Production bootstrap DI', () => { let savedEnv: Record = {}; - let graphRepoPath: string; let liteLLMServer: Server | undefined; beforeEach(async () => { savedEnv = {}; - graphRepoPath = mkdtempSync(path.join(os.tmpdir(), 'platform-bootstrap-di-')); - const overrides = { ...REQUIRED_ENV, GRAPH_REPO_PATH: graphRepoPath } as Record; + const overrides = { ...REQUIRED_ENV } as Record; for (const [key, value] of Object.entries(overrides)) { if (!(key in savedEnv)) { savedEnv[key] = process.env[key]; @@ -76,7 +72,6 @@ describe('Production bootstrap DI', () => { } } savedEnv = {}; - rmSync(graphRepoPath, { recursive: true, force: true }); }); it( 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/__fixtures__/graph-migration/invalid/nodes/broken.json b/packages/platform-server/__fixtures__/graph-migration/invalid/nodes/broken.json deleted file mode 100644 index 5b5de940e..000000000 --- a/packages/platform-server/__fixtures__/graph-migration/invalid/nodes/broken.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "broken-node", - "template": "github.clone_repo", - "config": { - "token": { - "source": "vault", - "value": "onlykey" - } - } -} diff --git a/packages/platform-server/__fixtures__/graph-migration/legacy/edges/github-clone__slack-trigger.json b/packages/platform-server/__fixtures__/graph-migration/legacy/edges/github-clone__slack-trigger.json deleted file mode 100644 index 07305585f..000000000 --- a/packages/platform-server/__fixtures__/graph-migration/legacy/edges/github-clone__slack-trigger.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "github-clone-output__slack-trigger-input", - "source": "github-clone", - "sourceHandle": "output", - "target": "slack-trigger", - "targetHandle": "input" -} diff --git a/packages/platform-server/__fixtures__/graph-migration/legacy/graph.meta.json b/packages/platform-server/__fixtures__/graph-migration/legacy/graph.meta.json deleted file mode 100644 index 6e11a7c8c..000000000 --- a/packages/platform-server/__fixtures__/graph-migration/legacy/graph.meta.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "main", - "version": 1, - "updatedAt": "2025-01-01T00:00:00.000Z", - "format": 2 -} diff --git a/packages/platform-server/__fixtures__/graph-migration/legacy/nodes/github%2Fclone.json b/packages/platform-server/__fixtures__/graph-migration/legacy/nodes/github%2Fclone.json deleted file mode 100644 index b10c8a95f..000000000 --- a/packages/platform-server/__fixtures__/graph-migration/legacy/nodes/github%2Fclone.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "id": "github-clone", - "template": "github.clone_repo", - "config": { - "token": { - "source": "vault", - "value": "workflows/github/token" - }, - "organization": "HautechAI", - "repository": "agents", - "branch": { - "source": "static", - "value": "main" - } - }, - "state": { - "auth": { - "source": "env", - "envVar": "GH_TOKEN" - } - } -} diff --git a/packages/platform-server/__fixtures__/graph-migration/legacy/nodes/slack%2Ftrigger.json b/packages/platform-server/__fixtures__/graph-migration/legacy/nodes/slack%2Ftrigger.json deleted file mode 100644 index d48ebff11..000000000 --- a/packages/platform-server/__fixtures__/graph-migration/legacy/nodes/slack%2Ftrigger.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "slack-trigger", - "template": "slack.trigger", - "config": { - "auth": { - "bot": { - "source": "vault", - "value": "workspace/app-token" - }, - "app": { - "source": "vault", - "value": "secret/api-key" - } - }, - "channel": { - "source": "static", - "value": "#deployments" - } - }, - "state": { - "appToken": { - "source": "env", - "envVar": "SLACK_APP_TOKEN", - "default": "fallback-token" - }, - "lastCursor": null - }, - "position": { - "x": 320, - "y": 180 - } -} 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__/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..88affaf6c 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, { + meta: { id: 'agent-configured' }, + title: ' Configured Agent ', + description: '', + config: create(AgentConfigSchema, { name: ' Casey ', role: ' Lead Engineer ' }), + }), + create(AgentSchema, { + meta: { id: 'agent-profile' }, + title: '', + description: '', + config: create(AgentConfigSchema, { name: ' Delta ', role: ' Support ' }), + }), + create(AgentSchema, { meta: { id: 'agent-template' }, title: '', description: '' }), + create(AgentSchema, { meta: { 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__/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__/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__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index 3bfdd43a8..3223e425d 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -14,6 +14,7 @@ const trackedEnvKeys = [ 'AGENTS_DATABASE_URL', 'AGENTS_ENV', 'AGENTS_DEPLOYMENT', + 'TEAMS_SERVICE_ADDR', 'DEPLOYMENT_ID', '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__/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.delete.integration.test.ts b/packages/platform-server/__tests__/containers.delete.integration.test.ts index 0fd7a3b56..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'; @@ -119,6 +118,32 @@ 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__/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(); } 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-ref-migrate/integration.test.ts b/packages/platform-server/__tests__/graph-ref-migrate/integration.test.ts deleted file mode 100644 index b987036d7..000000000 --- a/packages/platform-server/__tests__/graph-ref-migrate/integration.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { afterEach, describe, expect, it } from 'vitest'; - -import { runMigration } from '../../tools/graph-ref-migrate/run'; -import type { Logger, MigrationOptions } from '../../tools/graph-ref-migrate/types'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const fixturesDir = path.resolve(__dirname, '../../__fixtures__/graph-migration'); - -const createLogger = (): Logger => ({ - info() {}, - warn() {}, - error() {}, - verbose() {}, -}); - -const tempDirs: string[] = []; - -const copyFixture = async (fixtureName: string): Promise => { - const base = await fs.mkdtemp(path.join(os.tmpdir(), 'graph-ref-')); - tempDirs.push(base); - await fs.cp(path.join(fixturesDir, fixtureName), base, { recursive: true }); - return base; -}; - -const createOptions = (overrides: Partial & { cwd: string }): MigrationOptions => ({ - input: path.join(overrides.cwd, '**/*.json'), - includes: [], - excludes: [], - mode: 'dry-run', - backup: true, - defaultMount: 'secret', - knownMounts: ['secret'], - validateSchema: true, - verbose: false, - cwd: overrides.cwd, - ...overrides, -}); - -afterEach(async () => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) await fs.rm(dir, { recursive: true, force: true }); - } -}); - -describe('graph-ref-migrate integration', () => { - it('reports conversions without modifying files in dry-run mode', async () => { - const cwd = await copyFixture('legacy'); - const options = createOptions({ cwd, mode: 'dry-run', backup: true }); - - const summary = await runMigration(options, createLogger()); - - expect(summary.files.length).toBe(4); - const slackNode = summary.files.find((file) => file.path.endsWith('nodes/slack%2Ftrigger.json')); - expect(slackNode).toBeDefined(); - expect(slackNode?.changed).toBe(true); - expect(slackNode?.conversions).toContainEqual({ - pointer: '/config/auth/bot', - kind: 'vault', - legacy: 'vault', - usedDefaultMount: true, - }); - expect(slackNode?.errors).toContainEqual({ - pointer: '/config/auth/app', - message: 'Legacy vault reference missing path segment between mount and key', - }); - expect(slackNode?.errors).toContainEqual({ pointer: '/config/auth/app', message: 'Legacy reference remains after migration' }); - expect(slackNode?.skipped).toBe(true); - - const githubNode = summary.files.find((file) => file.path.endsWith('nodes/github%2Fclone.json')); - expect(githubNode).toBeDefined(); - expect(githubNode?.errors).toEqual([]); - expect(githubNode?.changed).toBe(true); - expect(githubNode?.conversions.length).toBeGreaterThan(0); - - // Ensure files remain unmodified (legacy refs still present) - const sample = await fs.readFile(path.join(cwd, 'nodes/slack%2Ftrigger.json'), 'utf8'); - expect(sample).toContain('"source": "vault"'); - const backups = await fs.readdir(path.join(cwd, 'nodes')); - expect(backups.some((name) => name.includes('.backup-'))).toBe(false); - }); - - it('writes migrations with backups and produces idempotent output', async () => { - const cwd = await copyFixture('legacy'); - const writeSummary = await runMigration(createOptions({ cwd, mode: 'write', backup: true }), createLogger()); - - const slackNode = writeSummary.files.find((file) => file.path.endsWith('nodes/slack%2Ftrigger.json')); - expect(slackNode?.skipped).toBe(true); - expect(slackNode?.conversions).toContainEqual({ - pointer: '/config/auth/bot', - kind: 'vault', - legacy: 'vault', - usedDefaultMount: true, - }); - expect(slackNode?.errors).toContainEqual({ - pointer: '/config/auth/app', - message: 'Legacy vault reference missing path segment between mount and key', - }); - - const githubNode = writeSummary.files.find((file) => file.path.endsWith('nodes/github%2Fclone.json')); - expect(githubNode?.skipped).not.toBe(true); - expect(githubNode?.errors).toEqual([]); - expect(githubNode?.changed).toBe(true); - - const nodePath = path.join(cwd, 'nodes/github%2Fclone.json'); - const updated = JSON.parse(await fs.readFile(nodePath, 'utf8')) as Record; - const token = (updated.config as { token: Record }).token as Record; - expect(token).toEqual({ kind: 'vault', mount: 'workflows', path: 'github', key: 'token' }); - - const dirEntries = await fs.readdir(path.join(cwd, 'nodes')); - expect(dirEntries.filter((name) => name.includes('.backup-')).length).toBeGreaterThan(0); - expect(dirEntries.some((name) => name.startsWith('slack%2Ftrigger.json.backup-'))).toBe(false); - - const secondSummary = await runMigration(createOptions({ cwd, mode: 'dry-run', backup: false }), createLogger()); - const secondSlack = secondSummary.files.find((file) => file.path.endsWith('nodes/slack%2Ftrigger.json')); - expect(secondSlack?.skipped).toBe(true); - expect(secondSlack?.errors).toContainEqual({ - pointer: '/config/auth/app', - message: 'Legacy vault reference missing path segment between mount and key', - }); - expect(secondSlack?.conversions).toContainEqual({ - pointer: '/config/auth/bot', - kind: 'vault', - legacy: 'vault', - usedDefaultMount: true, - }); - expect(secondSummary.files.filter((file) => file.errors.length === 0 && file.changed).length).toBe(0); - }); - - it('flags errors and skips writes when migration fails', async () => { - const cwd = await copyFixture('invalid'); - const summary = await runMigration(createOptions({ cwd, mode: 'write' }), createLogger()); - - expect(summary.files.some((file) => file.errors.length > 0)).toBe(true); - expect(summary.files.every((file) => file.skipped === true)).toBe(true); - - const raw = await fs.readFile(path.join(cwd, 'nodes/broken.json'), 'utf8'); - expect(raw).toContain('"source": "vault"'); - const backups = await fs.readdir(path.join(cwd, 'nodes')); - expect(backups.some((name) => name.includes('.backup-'))).toBe(false); - }); -}); diff --git a/packages/platform-server/__tests__/graph-ref-migrate/options.test.ts b/packages/platform-server/__tests__/graph-ref-migrate/options.test.ts deleted file mode 100644 index 59e366f40..000000000 --- a/packages/platform-server/__tests__/graph-ref-migrate/options.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import path from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { CliError, parseCliOptions } from '../../tools/graph-ref-migrate/options'; - -const cwd = process.cwd(); - -describe('parseCliOptions', () => { - it('parses required input with defaults', () => { - const options = parseCliOptions(['--input', 'graphs/*.json'], cwd); - - expect(options.mode).toBe('dry-run'); - expect(options.backup).toBe(true); - expect(options.defaultMount).toBe('secret'); - expect(options.knownMounts).toEqual(['secret']); - expect(options.validateSchema).toBe(true); - expect(options.verbose).toBe(false); - expect(options.includes).toEqual([]); - expect(options.excludes).toEqual([]); - expect(options.input).toBe(path.resolve(cwd, 'graphs/*.json')); - }); - - it('honors overrides and negated flags', () => { - const options = parseCliOptions( - [ - '--input', - './data', - '--include', - 'nodes/**/*.json', - '--include', - 'edges/**/*.json', - '--exclude', - '**/*.bak', - '--write', - '--no-backup', - '--default-mount', - 'kv', - '--known-mounts', - 'secret, kv ,internal,secret', - '--no-validate-schema', - '--verbose', - ], - cwd, - ); - - expect(options.mode).toBe('write'); - expect(options.backup).toBe(false); - expect(options.defaultMount).toBe('kv'); - expect(options.knownMounts).toEqual(['secret', 'kv', 'internal']); - expect(options.validateSchema).toBe(false); - expect(options.verbose).toBe(true); - expect(options.includes).toEqual(['nodes/**/*.json', 'edges/**/*.json']); - expect(options.excludes).toEqual(['**/*.bak']); - }); - - it('throws when mutually exclusive flags are provided', () => { - expect(() => parseCliOptions(['--input', 'graph.json', '--write', '--dry-run'], cwd)).toThrow(CliError); - }); -}); diff --git a/packages/platform-server/__tests__/graph-ref-migrate/transform.test.ts b/packages/platform-server/__tests__/graph-ref-migrate/transform.test.ts deleted file mode 100644 index 7a3d3ffdb..000000000 --- a/packages/platform-server/__tests__/graph-ref-migrate/transform.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { migrateValue } from '../../tools/graph-ref-migrate/transform'; - -const migrate = ( - value: unknown, - options?: { defaultMount?: string; knownMounts?: string[]; validate?: boolean }, -) => - migrateValue( - value, - { - defaultMount: options?.defaultMount ?? 'secret', - knownMounts: new Set(options?.knownMounts ?? ['secret']), - }, - { validate: options?.validate ?? true }, - ); - -describe('migrateValue', () => { - it('converts legacy vault references with explicit mount', () => { - const input = { - config: { - auth: { - bot: { source: 'vault', value: 'secret/slack/apps/bot-token' }, - }, - }, - }; - - const result = migrate(input); - - expect(result.errors).toEqual([]); - expect(result.conversions.map((c) => c.pointer)).toEqual(['/config/auth/bot']); - expect(result.value).toEqual({ - config: { - auth: { - bot: { kind: 'vault', mount: 'secret', path: 'slack/apps', key: 'bot-token' }, - }, - }, - }); - }); - - it('converts two-segment legacy vault references using default mount when first segment is unknown', () => { - const input = { token: { source: 'vault', value: 'workspace/app-token' } }; - - const result = migrate(input); - - expect(result.errors).toEqual([]); - expect(result.conversions).toEqual([ - { - pointer: '/token', - kind: 'vault', - legacy: 'vault', - usedDefaultMount: true, - }, - ]); - expect(result.value).toEqual({ - token: { kind: 'vault', mount: 'secret', path: 'workspace', key: 'app-token' }, - }); - }); - - it('flags two-segment legacy vault references as invalid when first segment matches known mount', () => { - const input = { token: { source: 'vault', value: 'secret/api-key' } }; - - const result = migrate(input); - - expect(result.changed).toBe(false); - expect(result.conversions).toEqual([]); - expect(result.errors).toContainEqual({ - pointer: '/token', - message: 'Legacy vault reference missing path segment between mount and key', - }); - expect(result.errors).toContainEqual({ pointer: '/token', message: 'Legacy reference remains after migration' }); - }); - - it('honors custom known mounts when evaluating two-segment legacy vault references', () => { - const input = { token: { source: 'vault', value: 'internal/api-key' } }; - - const result = migrate(input, { knownMounts: ['secret', 'internal'] }); - - expect(result.changed).toBe(false); - expect(result.conversions).toEqual([]); - expect(result.errors).toContainEqual({ - pointer: '/token', - message: 'Legacy vault reference missing path segment between mount and key', - }); - expect(result.errors).toContainEqual({ pointer: '/token', message: 'Legacy reference remains after migration' }); - }); - - it('converts legacy env and static references and recurses through arrays', () => { - const input = { - env: { source: 'env', envVar: 'GH_TOKEN', default: 'fallback' }, - tags: [ - { source: 'static', value: 'alpha' }, - { source: 'env', envVar: 'SECONDARY' }, - ], - }; - - const result = migrate(input); - - expect(result.errors).toEqual([]); - expect(result.changed).toBe(true); - expect( - result.conversions.map(({ pointer, kind, legacy }) => ({ pointer, kind, legacy })), - ).toEqual([ - { pointer: '/env', kind: 'var', legacy: 'env' }, - { pointer: '/tags/0', kind: 'static', legacy: 'static' }, - { pointer: '/tags/1', kind: 'var', legacy: 'env' }, - ]); - expect(result.value).toEqual({ - env: { kind: 'var', name: 'GH_TOKEN', default: 'fallback' }, - tags: ['alpha', { kind: 'var', name: 'SECONDARY' }], - }); - }); - - it('leaves canonical references untouched', () => { - const input = { - token: { kind: 'vault', mount: 'secret', path: 'services/github', key: 'token' }, - env: { kind: 'var', name: 'SLACK_TOKEN' }, - }; - - const result = migrate(input); - - expect(result.changed).toBe(false); - expect(result.conversions).toEqual([]); - expect(result.errors).toEqual([]); - expect(result.value).toEqual(input); - }); - - it('reports errors for unconvertible legacy vault references', () => { - const input = { secret: { source: 'vault', value: 'onlykey' } }; - - const result = migrate(input); - - expect(result.changed).toBe(false); - expect(result.conversions).toEqual([]); - expect(result.errors).toContainEqual({ - pointer: '/secret', - message: 'Legacy vault reference must include mount, path, and key segments', - }); - expect(result.errors).toContainEqual({ pointer: '/secret', message: 'Legacy reference remains after migration' }); - }); - - it('reports errors when legacy static value is not primitive', () => { - const input = { ref: { source: 'static', value: { nested: true } } }; - - const result = migrate(input); - - expect(result.changed).toBe(false); - expect(result.conversions).toEqual([]); - expect(result.errors).toContainEqual({ - pointer: '/ref', - message: 'Legacy static reference must resolve to a primitive value', - }); - expect(result.errors).toContainEqual({ pointer: '/ref', message: 'Legacy reference remains after migration' }); - }); - - it('validates persisted graph node config schema', () => { - const input = { - id: 'node-1', - template: 'example.node', - config: 'not-an-object', - }; - - const result = migrate(input); - - expect(result.errors).toContainEqual({ - pointer: '/config', - message: 'PersistedGraphNode.config must be an object when provided', - }); - }); -}); 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 9ccfc5e25..000000000 --- a/packages/platform-server/__tests__/graph.fs.persistence.integration.test.ts +++ /dev/null @@ -1,380 +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'], - 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__/graph.mcp.integration.test.ts b/packages/platform-server/__tests__/graph.mcp.integration.test.ts index ba3b5f86b..509e045a8 100644 --- a/packages/platform-server/__tests__/graph.mcp.integration.test.ts +++ b/packages/platform-server/__tests__/graph.mcp.integration.test.ts @@ -1,23 +1,23 @@ import { PassThrough } from 'node:stream'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } 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 { TeamsGraphSource } from '../src/graph/teamsGraph.source'; import type { GraphDefinition } from '../src/shared/types/graph.types'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import { RunSignalsRegistry } from '../src/agents/run-signals.service'; import { ReferenceResolverService } from '../src/utils/reference-resolver.service'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; import { WorkspaceProvider, type WorkspaceProviderCapabilities, @@ -120,11 +120,9 @@ class StubConfigService extends ConfigService { litellmMasterKey: 'sk-test', openaiBaseUrl: undefined, githubToken: 'test', - graphRepoPath: './data/graph', graphBranch: 'main', graphAuthorName: undefined, graphAuthorEmail: undefined, - graphLockTimeoutMs: 5000, vaultEnabled: false, vaultAddr: undefined, vaultToken: undefined, @@ -170,6 +168,9 @@ class StubLLMProvisioner extends LLMProvisioner { describe('Graph MCP integration', () => { it('constructs graph with mcpServer template without error (deferred start)', async () => { + const teamsGraphSourceStub = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(teamsGraphSourceStub, 'load').mockResolvedValue({ nodes: [], edges: [] }); + const module = await Test.createTestingModule({ providers: [ { provide: DOCKER_CLIENT, useValue: createDockerClientStub() }, @@ -186,10 +187,9 @@ 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, + { provide: TeamsGraphSource, useValue: teamsGraphSourceStub }, { provide: AgentsPersistenceService, useValue: { @@ -206,15 +206,8 @@ describe('Graph MCP integration', () => { const moduleRef = module.get(ModuleRef); const templateRegistry = buildTemplateRegistry({ moduleRef }); - 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 }) }; - const runtime = new LiveGraphRuntime(templateRegistry, new GraphRepoStub(), moduleRef, resolver as any); + const runtime = new LiveGraphRuntime(templateRegistry, teamsGraphSourceStub, moduleRef, resolver as any); const graph: GraphDefinition = { nodes: [ 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..7ce823803 100644 --- a/packages/platform-server/__tests__/graph.module.di.smoke.test.ts +++ b/packages/platform-server/__tests__/graph.module.di.smoke.test.ts @@ -21,12 +21,13 @@ import { GithubService } from '../src/infra/github/github.client'; import { PRService } from '../src/infra/github/pr.usecase'; import { ArchiveService } from '../src/infra/archive/archive.service'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import { GraphRepository } from '../src/graph/graph.repository'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; import { ModuleRef } from '@nestjs/core'; import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; import { GatewayModule } from '../src/gateway/gateway.module'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { buildConfigInput } from './helpers/config'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; process.env.LLM_PROVIDER = 'openai'; process.env.AGENTS_DATABASE_URL = process.env.AGENTS_DATABASE_URL || 'postgres://localhost:5432/test'; @@ -189,12 +190,8 @@ if (!shouldRunDbTests) { toSchema: vi.fn().mockResolvedValue([]), } satisfies Partial; - const graphRepositoryStub = { - 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 teamsGraphSourceStub = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(teamsGraphSourceStub, 'load').mockResolvedValue({ nodes: [], edges: [] }); const builder = Test.createTestingModule({ imports: [GraphApiModule, GatewayModule], @@ -228,9 +225,8 @@ if (!shouldRunDbTests) { builder.overrideProvider(PRService).useFactory(() => makeStub({})); builder.overrideProvider(ArchiveService).useFactory(() => makeStub({})); builder.overrideProvider(TemplateRegistry).useFactory(() => templateRegistryStub as TemplateRegistry); - builder.overrideProvider(GraphRepository).useFactory(() => graphRepositoryStub as GraphRepository); + builder.overrideProvider(TeamsGraphSource).useFactory(() => teamsGraphSourceStub); 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 a1ed00947..fbde5784a 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; @@ -243,12 +235,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'); @@ -297,7 +283,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/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/__tests__/helpers/docker.e2e.ts b/packages/platform-server/__tests__/helpers/docker.e2e.ts index 570ab2eb3..7a8f8c623 100644 --- a/packages/platform-server/__tests__/helpers/docker.e2e.ts +++ b/packages/platform-server/__tests__/helpers/docker.e2e.ts @@ -4,12 +4,14 @@ 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, ServerHttp2Session } 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 DEFAULT_SOCKET = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock'; @@ -33,21 +35,191 @@ 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; +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(); + 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 }); + registerRunnerServerSessions(server); + 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(); + } + + return { + grpcAddress, + close: async () => { + await shutdownRunnerServer(server); + if (previousSocket !== undefined) process.env.DOCKER_SOCKET = previousSocket; + else delete process.env.DOCKER_SOCKET; + }, + }; +} + +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); } - await waitForRunnerReadyOnAddress(runnerAddress, RUNNER_SECRET); + return { - grpcAddress: runnerAddress, - close: async () => undefined, + 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'); + }); + }, }; } -async function waitForRunnerReady(client: RunnerServiceGrpcClient, secret: string): Promise { +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()); + closeRunnerServerConnections(server); + }); +} + +async function waitForRunnerReady(client: RunnerServiceClient): Promise { await waitFor(async () => { try { - await callRunnerReady(client, secret); + await callRunnerReady(client); return true; } catch { return false; @@ -56,37 +228,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__/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 new file mode 100644 index 000000000..7730a01bf --- /dev/null +++ b/packages/platform-server/__tests__/helpers/teamsGrpc.stub.ts @@ -0,0 +1,103 @@ +import type { + Agent, + Attachment, + McpServer, + MemoryBucket, + 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 = { + 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 => { + 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 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 + size), + nextPageToken: nextOffset < items.length ? String(nextOffset) : '', + }; + }; + + 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: { pageSize: number; pageToken?: string; type?: Tool['type'] }) => { + const shouldFilter = typeof request.type === 'number' && request.type !== ToolType.UNSPECIFIED; + const { items, nextPageToken } = paginate( + shouldFilter ? tools.filter((tool) => tool.type === request.type) : tools, + 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 listWorkspaceConfigurations = options?.listWorkspaceConfigurations ?? + (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: { pageSize: number; pageToken?: string }) => { + const { items, nextPageToken } = paginate(memoryBuckets, request.pageSize, request.pageToken); + return { memoryBuckets: items, nextPageToken }; + }); + const listAttachments = options?.listAttachments ?? + (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.pageSize, + request.pageToken, + ); + return { attachments: items, nextPageToken }; + }); + + return { + listAgents, + listTools, + listMcpServers, + listWorkspaceConfigurations, + listMemoryBuckets, + listAttachments, + } as unknown as TeamsGrpcClient; +}; 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__/live.graph.runtime.simpleAgent.config.propagation.test.ts b/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts index c68a89515..5ff034608 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 @@ -1,8 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { Test } from '@nestjs/testing'; import { ModuleRef } from '@nestjs/core'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; -import { GraphRepository } from '../src/graph/graph.repository'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; import type { GraphDefinition } from '../src/shared/types/graph.types'; import { buildTemplateRegistry } from '../src/templates'; import { DOCKER_CLIENT } from '../src/infra/container/dockerClient.token'; @@ -10,6 +10,7 @@ import { ContainerRegistry } from '../src/infra/container/container.registry'; import { ConfigService } from '../src/core/services/config.service.js'; import type { Config } from '../src/core/services/config.service.js'; import { createDockerClientStub } from './helpers/dockerClient.stub'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; // PrismaService removed from test harness; use minimal DI stubs import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; import { AgentNode } from '../src/nodes/agent/agent.node'; @@ -37,11 +38,9 @@ describe('LiveGraphRuntime -> Agent config propagation', () => { githubInstallationId: 'test', openaiApiKey: 'test', githubToken: 'test', - graphRepoPath: './data/graph', graphBranch: 'main', graphAuthorName: undefined, graphAuthorEmail: undefined, - graphLockTimeoutMs: 5000, vaultEnabled: false, vaultAddr: undefined, vaultToken: undefined, @@ -95,14 +94,10 @@ describe('LiveGraphRuntime -> Agent config propagation', () => { .then((module) => { const moduleRef = module.get(ModuleRef); const registry = buildTemplateRegistry({ moduleRef }); - class StubRepo extends GraphRepository { - async initIfNeeded(): Promise {} - async get(): Promise { return null; } - async upsert(): Promise { throw new Error('not-implemented'); } - async upsertNodeState(): Promise {} - } + const graphSource = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(graphSource, 'load').mockResolvedValue({ nodes: [], edges: [] }); const resolver = { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) }; - const runtime = new LiveGraphRuntime(registry, new StubRepo(), moduleRef, resolver as any); + const runtime = new LiveGraphRuntime(registry, graphSource, moduleRef, resolver as any); return { runtime }; }); } 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..2b31922cc 100644 --- a/packages/platform-server/__tests__/manage.tool.test.ts +++ b/packages/platform-server/__tests__/manage.tool.test.ts @@ -11,7 +11,7 @@ import { ConfigService, configSchema } from '../src/core/services/config.service import { Signal } from '../src/signal'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; -import { GraphRepository } from '../src/graph/graph.repository'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; import type { LiveNode } from '../src/graph/liveGraph.types'; import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; import type { LLMContext } from '../src/llm/types'; @@ -21,6 +21,7 @@ import { ManageToolNode } from '../src/nodes/tools/manage/manage.node'; import { ReferenceResolverService } from '../src/utils/reference-resolver.service'; import { createReferenceResolverStub } from './helpers/reference-resolver.stub'; import { runnerConfigDefaults } from './helpers/config'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; class StubLLMProvisioner extends LLMProvisioner { async init(): Promise {} @@ -472,20 +473,16 @@ describe('ManageTool graph wiring', () => { .register('agent', { title: 'Agent', kind: 'agent' }, FakeAgentWithTools) .register('manageTool', { title: 'Manage', kind: 'tool' }, ManageToolNode); + const teamsGraphSourceStub = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(teamsGraphSourceStub, 'load').mockResolvedValue({ nodes: [], edges: [] }); + const runtimeModule = await Test.createTestingModule({ providers: [ LiveGraphRuntime, { provide: TemplateRegistry, useValue: registry }, { - provide: GraphRepository, - useValue: { - initIfNeeded: async () => {}, - get: async () => null, - upsert: async () => { - throw new Error('not-implemented'); - }, - upsertNodeState: async () => {}, - }, + provide: TeamsGraphSource, + useValue: teamsGraphSourceStub, }, { provide: ReferenceResolverService, useValue: createReferenceResolverStub().stub }, { provide: ModuleRef, useValue: moduleRef }, 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..be4b07a61 100644 --- a/packages/platform-server/__tests__/memory.runtime.integration.test.ts +++ b/packages/platform-server/__tests__/memory.runtime.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { PrismaClient, Prisma } from '@prisma/client'; import { SystemMessage } from '@agyn/llm'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; @@ -7,7 +7,7 @@ import { Signal } from '../src/signal'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import type { GraphDefinition } from '../src/shared/types/graph.types'; import Node from '../src/nodes/base/Node'; -import { GraphRepository } from '../src/graph/graph.repository'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; import { MemoryConnectorNode, type MemoryConnectorStaticConfig, @@ -16,6 +16,7 @@ import { PostgresMemoryEntitiesRepository } from '../src/nodes/memory/memory.rep import { MemoryService } from '../src/nodes/memory/memory.service'; import type { MemoryScope } from '../src/nodes/memory/memory.types'; import type { TemplatePortConfig } from '../src/graph/ports.types'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; const createMemoryService = (prisma: PrismaClient) => new MemoryService(new PostgresMemoryEntitiesRepository({ getClient: () => prisma } as any), { get: async () => null } as any); @@ -111,22 +112,13 @@ function makeRuntime( const templates = new TemplateRegistry(moduleRef as import('@nestjs/core').ModuleRef); templates.register('callModel', { title: 'CallModel', kind: 'tool' }, TestCallModelNode); templates.register('memory', { title: 'Memory', kind: 'tool' }, MemoryConnectorNode); - - class StubRepo extends GraphRepository { - async initIfNeeded(): Promise {} - async get(): Promise { - return null; - } - async upsert(): Promise { - throw new Error('not-implemented'); - } - async upsertNodeState(): Promise {} - } + const graphSource = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(graphSource, 'load').mockResolvedValue({ nodes: [], edges: [] }); // Cast moduleRef back to real ModuleRef type for LiveGraphRuntime ctor compatibility const resolver = { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) }; const runtime = new LiveGraphRuntime( templates, - new StubRepo(), + graphSource, moduleRef as import('@nestjs/core').ModuleRef, resolver as any, ); diff --git a/packages/platform-server/__tests__/memory.service.docs.test.ts b/packages/platform-server/__tests__/memory.service.docs.test.ts index f94ff19ac..24096ecc1 100644 --- a/packages/platform-server/__tests__/memory.service.docs.test.ts +++ b/packages/platform-server/__tests__/memory.service.docs.test.ts @@ -27,7 +27,7 @@ describe('MemoryService listDocs aggregation', () => { it('returns graph memory nodes when persistence is empty', async () => { const repo = createRepoStub([]); const graph = { - get: vi.fn().mockResolvedValue({ + load: vi.fn().mockResolvedValue({ ...baseGraph, nodes: [ { id: 'mem-global', template: 'memory', config: { scope: 'global' as MemoryScope } }, @@ -44,7 +44,7 @@ describe('MemoryService listDocs aggregation', () => { { nodeId: 'mem-global', scope: 'global' }, { nodeId: 'mem-threaded', scope: 'perThread' }, ]); - expect(graph.get).toHaveBeenCalledWith('main'); + expect(graph.load).toHaveBeenCalled(); }); it('augments perThread nodes with persisted thread IDs and filters stale rows', async () => { @@ -57,7 +57,7 @@ describe('MemoryService listDocs aggregation', () => { { nodeId: 'stale-node', threadId: 'orphan' }, ]); const graph = { - get: vi.fn().mockResolvedValue({ + load: vi.fn().mockResolvedValue({ ...baseGraph, nodes: [ { id: 'mem-global', template: 'memory', config: { scope: 'global' as MemoryScope } }, @@ -75,7 +75,7 @@ describe('MemoryService listDocs aggregation', () => { { nodeId: 'mem-threaded', scope: 'perThread', threadId: 'thread-1' }, { nodeId: 'mem-threaded', scope: 'perThread', threadId: 'thread-2' }, ]); - expect(graph.get).toHaveBeenCalledWith('main'); + expect(graph.load).toHaveBeenCalled(); }); it('falls back to persistence when graph fails', async () => { @@ -83,7 +83,7 @@ describe('MemoryService listDocs aggregation', () => { { nodeId: 'persist-global', threadId: null }, { nodeId: 'persist-thread', threadId: ' t1 ' }, ]); - const graph = { get: vi.fn().mockRejectedValue(new Error('boom')) }; + const graph = { load: vi.fn().mockRejectedValue(new Error('boom')) }; const svc = new MemoryService(repo, graph as any); const result = await svc.listDocs(); 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__/ncpsKey.service.test.ts b/packages/platform-server/__tests__/ncpsKey.service.test.ts index d066173ae..80c9f12c7 100644 --- a/packages/platform-server/__tests__/ncpsKey.service.test.ts +++ b/packages/platform-server/__tests__/ncpsKey.service.test.ts @@ -8,7 +8,7 @@ describe('NcpsKeyService', () => { githubAppId: 'x', githubAppPrivateKey: 'x', githubInstallationId: 'x', openaiApiKey: 'x', githubToken: 'x', llmProvider: 'openai', agentsDatabaseUrl: 'postgres://localhost/agents', litellmBaseUrl: 'http://localhost:4000', litellmMasterKey: 'sk-test', - graphRepoPath: './data/graph', graphBranch: 'main', + graphBranch: 'main', dockerMirrorUrl: 'http://registry-mirror:5000', nixAllowedChannels: 'nixpkgs-unstable', nixHttpTimeoutMs: '5000', nixCacheTtlMs: String(300000), nixCacheMax: '500', mcpToolsStaleTimeoutMs: '0', ncpsEnabled: 'true', ncpsUrl: 'http://ncps:8501', ncpsRefreshIntervalMs: '0', // disable periodic refresh for most tests diff --git a/packages/platform-server/__tests__/nix.controller.test.ts b/packages/platform-server/__tests__/nix.controller.test.ts index 6be60fff2..32c8a9f9a 100644 --- a/packages/platform-server/__tests__/nix.controller.test.ts +++ b/packages/platform-server/__tests__/nix.controller.test.ts @@ -69,7 +69,6 @@ describe.sequential('NixController', () => { agentsDatabaseUrl: 'postgres://localhost:5432/agents', litellmBaseUrl: 'http://localhost:4000', litellmMasterKey: 'sk-test', - graphRepoPath: './data/graph', graphBranch: 'main', dockerMirrorUrl: 'http://registry-mirror:5000', nixAllowedChannels: 'nixpkgs-unstable', diff --git a/packages/platform-server/__tests__/nix.e2e.test.ts b/packages/platform-server/__tests__/nix.e2e.test.ts index d78f5e692..5696c4146 100644 --- a/packages/platform-server/__tests__/nix.e2e.test.ts +++ b/packages/platform-server/__tests__/nix.e2e.test.ts @@ -19,7 +19,7 @@ describe('NixController E2E (Fastify)', () => { agentsDatabaseUrl: 'postgres://localhost:5432/agents', litellmBaseUrl: 'http://localhost:4000', litellmMasterKey: 'sk-test', - graphRepoPath: './data/graph', graphBranch: 'main', + graphBranch: 'main', dockerMirrorUrl: 'http://registry-mirror:5000', nixAllowedChannels: 'nixpkgs-unstable', nixHttpTimeoutMs: String(200), nixCacheTtlMs: String(5 * 60_000), nixCacheMax: String(500), mcpToolsStaleTimeoutMs: '0', ncpsEnabled: 'false', ncpsUrl: 'http://ncps:8501', diff --git a/packages/platform-server/__tests__/nixRepo.controller.test.ts b/packages/platform-server/__tests__/nixRepo.controller.test.ts index ec5708146..13b1c20b4 100644 --- a/packages/platform-server/__tests__/nixRepo.controller.test.ts +++ b/packages/platform-server/__tests__/nixRepo.controller.test.ts @@ -34,7 +34,6 @@ describe('NixRepoController', () => { agentsDatabaseUrl: 'postgres://localhost:5432/agents', litellmBaseUrl: 'http://localhost:4000', litellmMasterKey: 'sk-test', - graphRepoPath: './data/graph', graphBranch: 'main', dockerMirrorUrl: 'http://registry-mirror:5000', ...runnerConfigDefaults, @@ -116,7 +115,6 @@ describe('NixRepoController', () => { agentsDatabaseUrl: 'postgres://localhost:5432/agents', litellmBaseUrl: 'http://localhost:4000', litellmMasterKey: 'sk-test', - graphRepoPath: './data/graph', graphBranch: 'main', dockerMirrorUrl: 'http://registry-mirror:5000', nixRepoAllowlist: 'allowed/repo', 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__/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.node.actions.test.ts b/packages/platform-server/__tests__/routes.node.actions.test.ts index d0889af5a..5b5f26502 100644 --- a/packages/platform-server/__tests__/routes.node.actions.test.ts +++ b/packages/platform-server/__tests__/routes.node.actions.test.ts @@ -1,37 +1,44 @@ 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 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, + }; + return { + controller: new GraphController(templateRegistry as never, runtime 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 +51,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 b4da6a32c..000000000 --- a/packages/platform-server/__tests__/routes.variables.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 { - variableLocal = { - data: new Map(), - async findMany() { return Array.from(this.data.values()); }, - 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; } } - -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; - 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' } ]; - 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); - 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('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); - 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__/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__/runtime.api.helpers.test.ts b/packages/platform-server/__tests__/runtime.api.helpers.test.ts index 0debff3d9..b2ea08d97 100644 --- a/packages/platform-server/__tests__/runtime.api.helpers.test.ts +++ b/packages/platform-server/__tests__/runtime.api.helpers.test.ts @@ -4,32 +4,29 @@ import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import Node from '../src/nodes/base/Node'; // Capabilities removed; test updated to use Node lifecycle import { ModuleRef } from '@nestjs/core'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; class ModuleRefStub { create(Cls: new (...args: any[]) => T): T { return new Cls(); } } -import { GraphRepository } from '../src/graph/graph.repository'; -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; const registry = new TemplateRegistry(moduleRef); + const graphSource = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(graphSource, 'load').mockResolvedValue({ nodes: [], edges: [] }); const runtime = new LiveGraphRuntime( registry, - new StubRepo(), + graphSource, moduleRef, { resolve: async (input: unknown) => ({ output: input, report: {} as unknown }) } as any, ); return { registry, runtime }; } -describe('Runtime helpers and GraphRepository API surfaces', () => { +describe('Runtime helpers and graph source API surfaces', () => { it('provision/deprovision + status work against live nodes', async () => { const { registry, runtime } = makeRuntimeAndRegistry(); @@ -86,7 +83,7 @@ describe('Runtime helpers and GraphRepository API surfaces', () => { edges: [], }); - // Template schema via registry directly (GraphRepository now stateless for templates only) + // Template schema via registry directly const templates = await registry.toSchema(); const dynEntry = templates.find((t) => t.name === 'dyn'); expect(dynEntry).toBeTruthy(); diff --git a/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts b/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts index bf8eb0d91..197f9f8c1 100644 --- a/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts +++ b/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -import { GraphRepository } from '../src/graph/graph.repository.js'; +import { TeamsGraphSource } from '../src/graph/teamsGraph.source.js'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import type { TemplatePortConfig } from '../src/graph/ports.types'; @@ -10,6 +10,7 @@ import { ModuleRef } from '@nestjs/core'; import Node from '../src/nodes/base/Node'; import { MemoryNode, MemoryNodeStaticConfigSchema } from '../src/nodes/memory/memory.node'; import { AgentStaticConfigSchema } from '../src/nodes/agent/agent.node'; +import { createTeamsClientStub } from './helpers/teamsGrpc.stub'; type StrictAgentConfig = z.infer; @@ -40,21 +41,13 @@ const makeRuntime = ( const templates = new TemplateRegistry(moduleRef); templates.register('Memory', { title: 'Memory', kind: 'tool' }, MemoryNode as any); templates.register('StrictAgent', { title: 'Strict Agent', kind: 'agent' }, StrictAgentNode as any); - class StubRepo extends GraphRepository { - async initIfNeeded(): Promise {} - async get(): Promise { - return null; - } - async upsert(): Promise { - throw new Error('not-implemented'); - } - async upsertNodeState(): Promise {} - } + const graphSource = new TeamsGraphSource(createTeamsClientStub()); + vi.spyOn(graphSource, 'load').mockResolvedValue({ nodes: [], edges: [] }); const resolver = { resolve: async (input: unknown) => (resolveImpl ? resolveImpl(input) : ({ output: input, report: {} as unknown })), }; - const runtime = new LiveGraphRuntime(templates, new StubRepo(), moduleRef as any, resolver as any); + const runtime = new LiveGraphRuntime(templates, graphSource, moduleRef as any, resolver as any); return runtime; }; @@ -84,25 +77,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__/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__/socket.events.test.ts b/packages/platform-server/__tests__/socket.events.test.ts index 69c32e882..54f42c2c7 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: () => () => {}, @@ -68,7 +67,6 @@ describe('Socket events', () => { const payload = graphEmitter?.mock.calls[0]?.[1]; expect(payload).toMatchObject({ nodeId: 'n1', provisionStatus: { state: 'provisioning' } }); }); - it('emits node_state via NodeStateService bridge', async () => { const adapter = new FastifyAdapter(); const fastify = adapter.getInstance(); @@ -109,7 +107,6 @@ describe('Socket events', () => { 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(); @@ -121,7 +118,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 c6d748f73..11c24c226 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 c4f9ef7da..76d0c13f0 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 865b912ea..0954f2d8d 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 e3ecf1143..4d31ffe6e 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 }; @@ -46,7 +45,6 @@ const createEventsBusNoop = (): EventsBusService => subscribeToToolOutputChunk: () => () => undefined, subscribeToToolOutputTerminal: () => () => undefined, subscribeToReminderCount: () => () => undefined, - subscribeToNodeState: () => () => undefined, subscribeToThreadCreated: () => () => undefined, subscribeToThreadUpdated: () => () => undefined, subscribeToMessageCreated: () => () => undefined, @@ -234,13 +232,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, @@ -296,13 +291,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/__tests__/teams/teamsGrpc.client.test.ts b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts new file mode 100644 index 000000000..f2b8e60a8 --- /dev/null +++ b/packages/platform-server/__tests__/teams/teamsGrpc.client.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; +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'; + +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: Code) => HttpStatus; + grpcStatusToErrorCode: (grpcCode: Code) => string; + extractServiceErrorMessage: (error: ConnectError) => string; + call: ( + path: string, + schema: unknown, + request: Req, + method: keyof TeamsServiceClient, + timeoutMs?: number, + ) => Promise; +}; + +describe('TeamsGrpcClient', () => { + it('throws when address is blank', () => { + expect(() => new TeamsGrpcClient({ address: ' ' })).toThrow('TeamsGrpcClient requires a valid address'); + }); + + it.each([ + [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; + + 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 = new ConnectError('detailed message', Code.InvalidArgument); + + 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 = new ConnectError(' fallback message ', Code.Unknown); + + 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 = new ConnectError('', Code.Unknown); + + expect(internal.extractServiceErrorMessage(error)).toBe(DEFAULT_ERROR_MESSAGE); + }); + + it('applies the default request timeout to gRPC calls', async () => { + 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 () => { + 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__/teamsGraph.source.test.ts b/packages/platform-server/__tests__/teamsGraph.source.test.ts new file mode 100644 index 000000000..897e12002 --- /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, { + meta: { id: 'agent-1' }, + title: ' Agent One ', + description: '', + config: create(AgentConfigSchema, { name: ' Casey ', role: ' Lead ', model: 'gpt-4' }), + }); + const tool = create(ToolSchema, { + meta: { id: 'tool-shell' }, + name: ' Shell Tool ', + description: 'ignored', + type: ToolType.SHELL_COMMAND, + config: { mode: 'fast' }, + }); + const mcp = create(McpServerSchema, { + meta: { id: 'mcp-1' }, + title: ' MCP Server ', + config: { namespace: 'tools', command: 'run', workdir: '/srv', env: [{ name: 'TOKEN', value: 'secret' }] }, + }); + const detachedMcp = create(McpServerSchema, { + meta: { id: 'mcp-2' }, + title: ' Detached MCP ', + config: { namespace: 'detached', command: 'run2' }, + }); + const workspace = create(WorkspaceConfigurationSchema, { + meta: { id: 'workspace-1' }, + title: ' Workspace ', + config: create(WorkspaceConfigSchema, { + image: 'ubuntu', + platform: WorkspacePlatform.LINUX_AMD64, + initialScript: 'echo hi', + }), + }); + const detachedWorkspace = create(WorkspaceConfigurationSchema, { + meta: { id: 'workspace-2' }, + title: ' Workspace Two ', + config: create(WorkspaceConfigSchema, { + image: 'debian', + platform: WorkspacePlatform.LINUX_ARM64, + }), + }); + const memoryBucket = create(MemoryBucketSchema, { + meta: { id: 'memory-1' }, + title: ' Memory ', + config: create(MemoryBucketConfigSchema, { + scope: MemoryBucketScope.GLOBAL, + collectionPrefix: 'agents', + }), + }); + const attachments = [ + create(AttachmentSchema, { + meta: { id: 'attach-agent-tool' }, + kind: AttachmentKind.AGENT_TOOL, + sourceId: 'agent-1', + targetId: 'tool-shell', + sourceType: EntityType.AGENT, + targetType: EntityType.TOOL, + }), + create(AttachmentSchema, { + meta: { id: 'attach-agent-mcp' }, + kind: AttachmentKind.AGENT_MCP_SERVER, + sourceId: 'agent-1', + targetId: 'mcp-1', + sourceType: EntityType.AGENT, + targetType: EntityType.MCP_SERVER, + }), + create(AttachmentSchema, { + meta: { id: 'attach-agent-memory' }, + kind: AttachmentKind.AGENT_MEMORY_BUCKET, + sourceId: 'agent-1', + targetId: 'memory-1', + sourceType: EntityType.AGENT, + targetType: EntityType.MEMORY_BUCKET, + }), + create(AttachmentSchema, { + meta: { id: 'attach-agent-workspace' }, + kind: AttachmentKind.AGENT_WORKSPACE_CONFIGURATION, + sourceId: 'agent-1', + targetId: 'workspace-1', + sourceType: EntityType.AGENT, + targetType: EntityType.WORKSPACE_CONFIGURATION, + }), + create(AttachmentSchema, { + meta: { 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, { + meta: { 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/__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__/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/__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 d1e5a404a..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(); } @@ -112,7 +118,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 +141,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/devspace.yaml b/packages/platform-server/devspace.yaml index 2e7037df2..b0f2e430a 100644 --- a/packages/platform-server/devspace.yaml +++ b/packages/platform-server/devspace.yaml @@ -41,8 +41,6 @@ pipelines: env: - name: NODE_ENV value: development - - name: GRAPH_REPO_PATH - value: /opt/app/data/graph resources: requests: cpu: 500m diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 1221994a7..1567fdc9b 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -4,9 +4,6 @@ "private": true, "main": "src/index.ts", "type": "module", - "bin": { - "graph-ref-migrate": "./tools/graph-ref-migrate/bin.mjs" - }, "scripts": { "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json", @@ -21,15 +18,16 @@ "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", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-node": "^2.1.1", - "@grpc/grpc-js": "^1.12.2", "@fastify/cors": "^11.1.0", "@fastify/websocket": "^11.2.0", + "@grpc/grpc-js": "^1.12.2", "@langchain/core": "1.0.1", "@langchain/langgraph": "1.0.0", "@langchain/langgraph-checkpoint": "1.0.0", @@ -56,9 +54,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", @@ -78,8 +76,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/prisma/schema.prisma b/packages/platform-server/prisma/schema.prisma index 57c535ee4..814f8c385 100644 --- a/packages/platform-server/prisma/schema.prisma +++ b/packages/platform-server/prisma/schema.prisma @@ -21,15 +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 -} - 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 59713404f..a01fa92d3 100644 --- a/packages/platform-server/src/agents/agents.persistence.service.ts +++ b/packages/platform-server/src/agents/agents.persistence.service.ts @@ -6,18 +6,21 @@ 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'; 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'; @@ -58,8 +61,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 +1261,59 @@ 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 = readString(thread.assignedAgentNodeId); + 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 = readString(agent.meta?.id); + if (!id) continue; + agentById.set(id, agent); + } + for (const thread of threads) { - const assignedId = typeof thread.assignedAgentNodeId === 'string' ? thread.assignedAgentNodeId.trim() : ''; + const assignedId = readString(thread.assignedAgentNodeId); 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 listAllPages((pageToken, pageSize) => + this.teamsClient + .listAgents(create(ListAgentsRequestSchema, { pageSize, pageToken: pageToken ?? '' })) + .then((response) => ({ items: response.agents, nextPageToken: response.nextPageToken })), + ); + } + + private buildAgentDescriptor(agent: Agent, fallback: string): AgentDescriptor { + 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 }; + if (name) descriptor.name = name; + if (role) descriptor.role = role; + return descriptor; + } + 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/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 8b33b90bb..05b7e7e2e 100644 --- a/packages/platform-server/src/core/services/config.service.ts +++ b/packages/platform-server/src/core/services/config.service.ts @@ -37,7 +37,6 @@ export const configSchema = z.object({ .transform((models) => models.map((model) => model.trim()).filter((model) => model.length > 0)), githubToken: z.string().min(1).optional(), // Graph persistence - graphRepoPath: z.string().default('./data/graph'), graphBranch: z .string() .default('main') @@ -47,13 +46,10 @@ export const configSchema = z.object({ }), graphAuthorName: z.string().optional(), graphAuthorEmail: z.string().optional(), - graphLockTimeoutMs: z - .union([z.string(), z.number()]) - .default('5000') - .transform((v) => { - 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()]) @@ -351,9 +347,6 @@ export class ConfigService implements Config { } // Graph config accessors - get graphRepoPath(): string { - return this.params.graphRepoPath; - } get graphBranch(): string { return this.params.graphBranch; } @@ -363,8 +356,8 @@ export class ConfigService implements Config { get graphAuthorEmail(): string | undefined { return this.params.graphAuthorEmail; } - get graphLockTimeoutMs(): number { - return this.params.graphLockTimeoutMs; + get teamsServiceAddr(): string { + return this.params.teamsServiceAddr; } // Vault getters (optional) @@ -558,7 +551,6 @@ export class ConfigService implements Config { const legacy = process.env.NCPS_URL; const urlServer = process.env.NCPS_URL_SERVER || legacy; const urlContainer = process.env.NCPS_URL_CONTAINER || legacy; - const graphRepoPathEnv = process.env.GRAPH_REPO_PATH; const graphBranchEnv = process.env.GRAPH_BRANCH; const dockerRunnerPortEnv = process.env.DOCKER_RUNNER_PORT ?? process.env.DOCKER_RUNNER_GRPC_PORT; const parsed = configSchema.parse({ @@ -575,11 +567,10 @@ export class ConfigService implements Config { litellmModels, githubToken: process.env.GH_TOKEN, // Pass raw env; schema will validate/assign default - graphRepoPath: graphRepoPathEnv, graphBranch: graphBranchEnv, 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/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 8e81253c2..34e4235d8 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, @@ -47,7 +46,6 @@ 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; @@ -125,7 +123,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)); @@ -347,19 +344,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); @@ -460,7 +444,7 @@ export class GraphSocketGateway implements OnModuleInit, OnModuleDestroy { }; private broadcast( - event: 'node_status' | 'node_state' | 'node_reminder_count', + event: 'node_status' | 'node_reminder_count' | 'node_state', payload: T, schema: z.ZodType, ) { @@ -490,15 +474,15 @@ 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 { + /** Emit node_state event for node state updates. */ + emitNodeState(nodeId: string, state: Record): 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..9d91cd022 100644 --- a/packages/platform-server/src/graph-core/liveGraph.manager.ts +++ b/packages/platform-server/src/graph-core/liveGraph.manager.ts @@ -17,7 +17,7 @@ import type Node from '../nodes/base/Node'; import { Errors } from '../graph/errors'; import { PortsRegistry } from '../graph/ports.registry'; import type { TemplatePortConfig } from '../graph/ports.types'; -import { GraphRepository } from '../graph/graph.repository'; +import { TeamsGraphSource, type TeamsGraphSnapshot } from '../graph/teamsGraph.source'; import { TemplateRegistry } from './templateRegistry'; import { ReferenceResolverService } from '../utils/reference-resolver.service'; import { ResolveError } from '../utils/references'; @@ -44,10 +44,9 @@ export class LiveGraphRuntime { (ev: { nodeId: string; prev: NodeStatusState; next: NodeStatusState; at: number }) => void >(); private nodeStatusHandlers = new Map void>(); - private graphName = 'main'; constructor( @Inject(TemplateRegistry) private readonly templateRegistry: TemplateRegistry, - @Inject(GraphRepository) private readonly graphs: GraphRepository, + @Inject(TeamsGraphSource) private readonly graphSource: TeamsGraphSource, @Inject(ModuleRef) private readonly moduleRef: ModuleRef, @Inject(ReferenceResolverService) private readonly referenceResolver: ReferenceResolverService, ) { @@ -91,57 +90,43 @@ export class LiveGraphRuntime { * Does not throw on failure; logs and returns { applied: false }. */ public async load(): Promise<{ applied: boolean; version?: number }> { - const name = 'main'; - this.graphName = name; - const toRuntimeGraph = (saved: { - nodes: Array<{ - id: string; - template: string; - config?: Record; - state?: Record; - }>; - edges: Array<{ source: string; sourceHandle: string; target: string; targetHandle: string }>; - version: number; - }) => - ({ - 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; + const toRuntimeGraph = (snapshot: TeamsGraphSnapshot): GraphDefinition => ({ + nodes: snapshot.nodes.map((node) => ({ + id: node.id, + data: { template: node.template, config: node.config }, + })), + edges: snapshot.edges.map((edge) => ({ + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + })), + }); try { - const existing = await this.graphs.get(name); - if (existing) { - this.logger.log( - `Applying persisted graph to live runtime ${JSON.stringify({ - version: existing.version, - nodes: existing.nodes.length, - edges: existing.edges.length, - })}`, - ); - await this.apply(toRuntimeGraph(existing)); - this.logger.log('Initial persisted graph applied successfully'); - return { applied: true, version: existing.version }; - } else { - this.logger.log('No persisted graph found; starting with empty runtime graph.'); + const snapshot = await this.graphSource.load(); + if (snapshot.nodes.length === 0 && snapshot.edges.length === 0) { + this.logger.log('No graph snapshot found; starting with empty runtime graph.'); return { applied: false }; } + this.logger.log( + `Applying graph snapshot to live runtime ${JSON.stringify({ + nodes: snapshot.nodes.length, + edges: snapshot.edges.length, + })}`, + ); + await this.apply(toRuntimeGraph(snapshot)); + this.logger.log('Initial graph snapshot applied successfully'); + return { applied: true }; } catch (e) { if (e instanceof GraphError) { const cause = e && typeof e === 'object' && 'cause' in e ? (e as { cause?: unknown }).cause : undefined; this.logger.error( - `Failed to apply initial persisted graph ${JSON.stringify({ message: e.message, cause: String(cause) })}`, + `Failed to apply initial graph snapshot ${JSON.stringify({ message: e.message, cause: String(cause) })}`, ); } this.logger.error( - `Failed to apply initial persisted graph ${JSON.stringify( + `Failed to apply initial graph snapshot ${JSON.stringify( e instanceof Error ? { name: e.name, message: e.message, stack: e.stack } : { @@ -163,15 +148,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 +171,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 +230,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 +250,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 +373,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 @@ -463,7 +431,6 @@ export class LiveGraphRuntime { private async resolveNodeConfig(nodeId: string, config: Record): Promise> { try { const { output } = await this.referenceResolver.resolve>(config, { - graphName: this.graphName, basePath: `/nodes/${encodeURIComponent(nodeId)}/config`, }); return output; 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..6b6c579d8 100644 --- a/packages/platform-server/src/graph-domain/graph-domain.module.ts +++ b/packages/platform-server/src/graph-domain/graph-domain.module.ts @@ -1,14 +1,11 @@ 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 { 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'; @@ -16,27 +13,18 @@ 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() @Module({ - imports: [CoreModule, EnvModule, EventsModule, InfraModule, VaultModule, LLMModule, NodesModule], + imports: [CoreModule, EnvModule, EventsModule, InfraModule, VaultModule, LLMModule, NodesModule, TeamsModule], providers: [ ThreadsMetricsService, RunSignalsRegistry, CallAgentLinkingService, ThreadCleanupCoordinator, RemindersService, - { - provide: GraphRepository, - useFactory: async (config: ConfigService, moduleRef: ModuleRef) => { - const templateRegistry = await moduleRef.resolve(TemplateRegistry, undefined, { strict: false }); - const repo = new FsGraphRepository(config, templateRegistry); - await repo.initIfNeeded(); - return repo; - }, - inject: [ConfigService, ModuleRef], - }, + TeamsGraphSource, AgentsPersistenceService, ], exports: [ @@ -45,7 +33,7 @@ import { TemplateRegistry } from '../graph-core/templateRegistry'; InfraModule, VaultModule, LLMModule, - GraphRepository, + TeamsGraphSource, NodesModule, AgentsPersistenceService, ThreadCleanupCoordinator, diff --git a/packages/platform-server/src/graph/controllers/graph.controller.ts b/packages/platform-server/src/graph/controllers/graph.controller.ts index 3f4be3896..7a42b2021 100644 --- a/packages/platform-server/src/graph/controllers/graph.controller.ts +++ b/packages/platform-server/src/graph/controllers/graph.controller.ts @@ -1,17 +1,16 @@ -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 { LiveGraphRuntime } from '../../graph-core/liveGraph.manager'; import type { NodeStatusState } from '../../nodes/base/Node'; -import { NodeStateService } from '../nodeState.service'; +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, ) {} @Get('templates') @@ -26,33 +25,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 4b0bddbc1..000000000 --- a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts +++ /dev/null @@ -1,161 +0,0 @@ -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; -}; - -@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, - ) {} - - @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; - } - -@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/controllers/graphVariables.controller.ts b/packages/platform-server/src/graph/controllers/graphVariables.controller.ts deleted file mode 100644 index 168ec67b6..000000000 --- a/packages/platform-server/src/graph/controllers/graphVariables.controller.ts +++ /dev/null @@ -1,85 +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); - if (isCodeError(e) && e.code === 'VERSION_CONFLICT') throw new HttpException({ error: 'VERSION_CONFLICT', current: e.current }, 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 === '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; - } - } - - @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; - } - } -} - -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/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/graph-api.module.ts b/packages/platform-server/src/graph/graph-api.module.ts index 7f8714e21..327a313d5 100644 --- a/packages/platform-server/src/graph/graph-api.module.ts +++ b/packages/platform-server/src/graph/graph-api.module.ts @@ -3,32 +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 { GraphGuard } from './graph.guard'; -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: [GraphGuard, NodeStateService, GraphVariablesService], - exports: [GraphCoreModule, NodeStateService, GraphVariablesService], + providers: [], + exports: [GraphCoreModule], }) export class GraphApiModule {} 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/graph/graph.repository.ts b/packages/platform-server/src/graph/graph.repository.ts deleted file mode 100644 index 63a7103a2..000000000 --- a/packages/platform-server/src/graph/graph.repository.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { - PersistedGraph, - PersistedGraphUpsertRequest, - PersistedGraphUpsertResponse, -} from '../shared/types/graph.types'; - -export type GraphAuthor = { name?: string; email?: string }; - -// Abstract repository token for DI and implementation unification. -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/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/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/index.ts b/packages/platform-server/src/graph/index.ts index 107b49169..d655008d8 100644 --- a/packages/platform-server/src/graph/index.ts +++ b/packages/platform-server/src/graph/index.ts @@ -5,6 +5,5 @@ export * from './liveGraph.types'; export * from '../graph-core/liveGraph.manager'; export * from './ports.types'; export * from './ports.registry'; -export * from './graph.repository'; -export * from './fsGraph.repository'; +export * from './teamsGraph.source'; export * from './graph-api.module'; 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 78a4702cf..000000000 --- a/packages/platform-server/src/graph/services/graphVariables.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -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, - ) {} - - 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); - const prisma = this.prismaService.getClient(); - const locals = await 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 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 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; - } - 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 - 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; - } - } - // 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 } }); - } - 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 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; - } - } - const prisma = this.prismaService.getClient(); - 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 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; - return undefined; - } -} 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..473267bbe --- /dev/null +++ b/packages/platform-server/src/graph/teamsGraph.source.ts @@ -0,0 +1,579 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { create } from '@bufbuild/protobuf'; + +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, + AgentWhenBusy, + Attachment, + AttachmentKind, + EntityType, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + McpToolFilter, + McpToolFilterMode, + McpServer, + MemoryBucket, + MemoryBucketScope, + Tool, + ToolType, + WorkspaceConfig, + WorkspaceConfiguration, + WorkspacePlatform, +} from '../proto/gen/agynio/api/teams/v1/teams_pb'; + +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 = 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.meta?.id); + if (!id) continue; + addNode({ id, template: 'agent', config: this.mapAgentConfig(agent) }); + } + + for (const tool of tools) { + const id = this.normalizeId(tool.meta?.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.meta?.id); + if (!id) continue; + addNode({ id, template: 'mcpServer', config: this.mapMcpConfig(mcp) }); + } + + for (const workspace of workspaces) { + 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.meta?.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 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((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.meta?.id); + if (!id || collected.has(id)) continue; + collected.set(id, tool); + } + return Array.from(collected.values()); + } + + private async listAllMcpServers(): Promise { + 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((pageToken, pageSize) => + this.teams + .listWorkspaceConfigurations( + create(ListWorkspaceConfigurationsRequestSchema, { pageSize, pageToken: pageToken ?? '' }), + ) + .then((response) => ({ items: response.workspaceConfigurations, nextPageToken: response.nextPageToken })), + ); + } + + private async listAllMemoryBuckets(): Promise { + 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((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.meta?.id) ?? `${attachment.kind}:${attachment.sourceId}:${attachment.targetId}`; + if (collected.has(key)) continue; + collected.set(key, attachment); + } + } + return Array.from(collected.values()); + } + + private mapAgentConfig(agent: Agent): Record | undefined { + const config: Record = {}; + const title = readString(agent.title); + if (title) config.title = title; + + const raw = agent.config; + if (raw) { + const model = 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 = readString(raw.name); + if (name) config.name = name; + const role = 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 = 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 = readString(mcp.title); + if (title) config.title = title; + const raw = mcp.config; + if (raw) { + const namespace = readString(raw.namespace); + if (namespace) config.namespace = namespace; + const command = readString(raw.command); + if (command) config.command = command; + const workdir = 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; + } + const toolFilter = this.mapMcpToolFilter(raw.toolFilter); + if (toolFilter) config.toolFilter = toolFilter; + } + return Object.keys(config).length > 0 ? config : undefined; + } + + private mapWorkspaceConfig(workspace: WorkspaceConfiguration): Record | undefined { + const config: Record = {}; + const title = readString(workspace.title); + if (title) config.title = title; + const raw = workspace.config; + if (raw) { + const image = 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 = 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 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: + 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 = readString(item.name); + if (!name) continue; + mapped.push({ name, value: item.value }); + } + return mapped.length > 0 ? mapped : undefined; + } + + private readValue(value?: WorkspaceConfig['cpuLimit']): string | undefined { + return readString(value); + } + + 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 readOptionalString(value?: string): string | undefined { + if (typeof value !== 'string') return undefined; + return value; + } + + private normalizeId(value?: string): string | undefined { + return 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 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; + } +} 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/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..3d9d82105 100644 --- a/packages/platform-server/src/infra/container/runnerGrpc.client.ts +++ b/packages/platform-server/src/infra/container/runnerGrpc.client.ts @@ -1,22 +1,24 @@ 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 { buildAuthHeaders } from './auth'; import type { ContainerInspectInfo, ContainerOpts, DockerEventFilters, + ExecInspectInfo, ExecOptions, ExecResult, InteractiveExecOptions, @@ -26,72 +28,56 @@ import type { } from './dockerRunner.types'; 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,52 @@ 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 emitStreamError = (error: DockerRunnerRequestError): void => { + try { + stream.emit('error', error); + } catch (emitError) { + this.logger.warn('Runner events stream error before listeners attached', { + error, + emitError, + }); } - }); + }; - call.on('error', (err: ServiceError) => { - stream.emit('error', this.translateServiceError(err, { path: RUNNER_SERVICE_STREAM_EVENTS_PATH })); - }); + 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') { + emitStreamError(this.runnerErrorToException(event.value, 'runner_events_error')); + } + } + } catch (error) { + if (!abortController.signal.aborted) { + emitStreamError(this.translateServiceError(error, { path })); + } + } finally { + stream.end(); + } + }; - call.on('end', () => { - stream.end(); + setImmediate(() => { + void pump(); }); stream.on('close', () => { - call.cancel(); + abortController.abort(); }); return stream; @@ -544,54 +547,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 +601,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 +678,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 +727,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 +786,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 +863,43 @@ export class RunnerGrpcExecClient { idleTimeoutMs: requestedIdleTimeoutMs, }); if (timeoutError) { - fail(timeoutError); - return; + throw timeoutError; } - finalize({ exitCode: event.value.exitCode, stdout, stderr }); - return; - } - 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; + return { exitCode: event.value.exitCode, stdout, stderr }; } - 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 +907,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 +927,7 @@ export class RunnerGrpcExecClient { }); }); let execId: string | undefined; + const resolvedExecIdRef: { current?: string } = {}; let finished = false; let finalResult: ExecResult | undefined; let cancelledLocally = false; @@ -933,8 +948,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 +987,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)); + const translated = this.translateServiceError(connectError, { path: runnerServicePath('exec') }); + fail(translated); + } finally { + requestStream.end(); } - }); - - 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; - } - 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 +1120,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 +1142,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 +1173,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 +1184,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 +1210,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 +1238,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 +1361,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 +1376,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 +1403,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/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/nodes/memory/memory.service.ts b/packages/platform-server/src/nodes/memory/memory.service.ts index f0266e85d..181bf5a7d 100644 --- a/packages/platform-server/src/nodes/memory/memory.service.ts +++ b/packages/platform-server/src/nodes/memory/memory.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { GraphRepository } from '../../graph/graph.repository'; +import { TeamsGraphSource } from '../../graph/teamsGraph.source'; import { PostgresMemoryEntitiesRepository, type MemoryEntitiesRepositoryPort, @@ -13,7 +13,7 @@ const VALID_SEGMENT = /^[A-Za-z0-9_. -]+$/; export class MemoryService { constructor( @Inject(PostgresMemoryEntitiesRepository) private readonly repo: MemoryEntitiesRepositoryPort, - @Inject(GraphRepository) private readonly graphRepo: GraphRepository, + @Inject(TeamsGraphSource) private readonly graphSource: TeamsGraphSource, ) {} normalizePath(rawPath: string, opts: { allowRoot?: boolean } = {}): string { @@ -54,16 +54,16 @@ export class MemoryService { const rows = await this.repo.listDistinctNodeThreads(); let graph = null; try { - graph = await this.graphRepo.get('main'); + graph = await this.graphSource.load(); } catch { graph = null; } - if (!graph) { + if (!graph || (graph.nodes.length === 0 && graph.edges.length === 0)) { return this.buildDocsFromPersistence(rows); } - return this.buildDocsFromGraph(graph.nodes ?? [], rows); + return this.buildDocsFromGraph(graph.nodes, rows); } private getSegments(path: string): string[] { 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/proto/grpc.ts b/packages/platform-server/src/proto/grpc.ts index ca2e4221c..2a93cbb03 100644 --- a/packages/platform-server/src/proto/grpc.ts +++ b/packages/platform-server/src/proto/grpc.ts @@ -34,6 +34,76 @@ import { TouchWorkloadRequestSchema, TouchWorkloadResponseSchema, } from './gen/agynio/api/runner/v1/runner_pb.js'; +import { + CreateAgentRequestSchema, + CreateAgentResponseSchema, + CreateAttachmentRequestSchema, + CreateAttachmentResponseSchema, + CreateMcpServerRequestSchema, + CreateMcpServerResponseSchema, + CreateMemoryBucketRequestSchema, + CreateMemoryBucketResponseSchema, + CreateToolRequestSchema, + CreateToolResponseSchema, + CreateVariableRequestSchema, + CreateVariableResponseSchema, + CreateWorkspaceConfigurationRequestSchema, + 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, + ResolveVariableRequestSchema, + ResolveVariableResponseSchema, + UpdateAgentRequestSchema, + UpdateAgentResponseSchema, + UpdateMcpServerRequestSchema, + UpdateMcpServerResponseSchema, + UpdateMemoryBucketRequestSchema, + UpdateMemoryBucketResponseSchema, + UpdateToolRequestSchema, + UpdateToolResponseSchema, + UpdateVariableRequestSchema, + UpdateVariableResponseSchema, + UpdateWorkspaceConfigurationRequestSchema, + UpdateWorkspaceConfigurationResponseSchema, +} from './gen/agynio/api/teams/v1/teams_pb.js'; const unaryDefinition = ( path: string, @@ -180,3 +250,223 @@ 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_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'; + +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, + ), + 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, + 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/shared/types/graph.types.ts b/packages/platform-server/src/shared/types/graph.types.ts index c5c6bef7b..409559caf 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,4 @@ 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/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..5a786a1c6 --- /dev/null +++ b/packages/platform-server/src/teams/teamsGrpc.client.ts @@ -0,0 +1,647 @@ +import { create, type DescMessage } from '@bufbuild/protobuf'; +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, + CreateVariableRequestSchema, + CreateWorkspaceConfigurationRequestSchema, + DeleteAgentRequestSchema, + DeleteAttachmentRequestSchema, + DeleteMcpServerRequestSchema, + DeleteMemoryBucketRequestSchema, + DeleteToolRequestSchema, + DeleteVariableRequestSchema, + DeleteWorkspaceConfigurationRequestSchema, + GetAgentRequestSchema, + GetMcpServerRequestSchema, + GetMemoryBucketRequestSchema, + GetToolRequestSchema, + GetVariableRequestSchema, + GetWorkspaceConfigurationRequestSchema, + ListAgentsRequestSchema, + ListAttachmentsRequestSchema, + ListMcpServersRequestSchema, + ListMemoryBucketsRequestSchema, + ListToolsRequestSchema, + ListVariablesRequestSchema, + ListWorkspaceConfigurationsRequestSchema, + ResolveVariableRequestSchema, + TeamsService, + UpdateAgentRequestSchema, + UpdateMcpServerRequestSchema, + UpdateMemoryBucketRequestSchema, + UpdateToolRequestSchema, + UpdateVariableRequestSchema, + UpdateWorkspaceConfigurationRequestSchema, +} from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; +import type { + Agent, + Attachment, + CreateAgentResponse, + CreateAgentRequest, + CreateAttachmentResponse, + CreateAttachmentRequest, + CreateMcpServerRequest, + CreateMcpServerResponse, + CreateMemoryBucketRequest, + CreateMemoryBucketResponse, + CreateToolRequest, + CreateToolResponse, + CreateVariableRequest, + CreateVariableResponse, + CreateWorkspaceConfigurationRequest, + 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, + MemoryBucket, + ResolveVariableRequest, + ResolveVariableResponse, + Tool, + UpdateAgentRequest, + UpdateAgentResponse, + UpdateMcpServerRequest, + UpdateMcpServerResponse, + UpdateMemoryBucketRequest, + UpdateMemoryBucketResponse, + UpdateToolRequest, + UpdateToolResponse, + UpdateVariableRequest, + UpdateVariableResponse, + UpdateWorkspaceConfigurationRequest, + UpdateWorkspaceConfigurationResponse, + Variable, + WorkspaceConfiguration, +} from '../proto/gen/agynio/api/teams/v1/teams_pb.js'; + +type TeamsGrpcClientConfig = { + address: string; + requestTimeoutMs?: number; +}; + +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: Code, + readonly errorCode: string, + message: string, + ) { + super({ error: errorCode, message, grpcCode }, statusCode); + this.name = 'TeamsGrpcRequestError'; + } +} + +export class TeamsGrpcClient { + private readonly client: TeamsServiceClient; + 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; + const baseUrl = normalizeBaseUrl(address); + this.client = createClient(TeamsService, createGrpcTransport({ baseUrl })); + } + + getEndpoint(): string { + return this.endpoint; + } + + async listAgents(request: ListAgentsRequest): Promise { + return this.call( + teamsServicePath('listAgents'), + ListAgentsRequestSchema, + request, + 'listAgents', + ); + } + + async createAgent(request: CreateAgentRequest): Promise { + const response = await this.call( + teamsServicePath('createAgent'), + CreateAgentRequestSchema, + request, + 'createAgent', + ); + return this.requireResponseField(response.agent, 'agent'); + } + + async getAgent(request: GetAgentRequest): Promise { + const response = await this.call( + teamsServicePath('getAgent'), + GetAgentRequestSchema, + request, + 'getAgent', + ); + return this.requireResponseField(response.agent, 'agent'); + } + + async updateAgent(request: UpdateAgentRequest): Promise { + const response = await this.call( + teamsServicePath('updateAgent'), + UpdateAgentRequestSchema, + request, + 'updateAgent', + ); + return this.requireResponseField(response.agent, 'agent'); + } + + async deleteAgent(request: DeleteAgentRequest): Promise { + await this.call( + teamsServicePath('deleteAgent'), + DeleteAgentRequestSchema, + request, + 'deleteAgent', + ); + } + + async listTools(request: ListToolsRequest): Promise { + return this.call( + teamsServicePath('listTools'), + ListToolsRequestSchema, + request, + 'listTools', + ); + } + + async createTool(request: CreateToolRequest): Promise { + const response = await this.call( + teamsServicePath('createTool'), + CreateToolRequestSchema, + request, + 'createTool', + ); + return this.requireResponseField(response.tool, 'tool'); + } + + async getTool(request: GetToolRequest): Promise { + const response = await this.call( + teamsServicePath('getTool'), + GetToolRequestSchema, + request, + 'getTool', + ); + return this.requireResponseField(response.tool, 'tool'); + } + + async updateTool(request: UpdateToolRequest): Promise { + const response = await this.call( + teamsServicePath('updateTool'), + UpdateToolRequestSchema, + request, + 'updateTool', + ); + return this.requireResponseField(response.tool, 'tool'); + } + + async deleteTool(request: DeleteToolRequest): Promise { + await this.call( + teamsServicePath('deleteTool'), + DeleteToolRequestSchema, + request, + 'deleteTool', + ); + } + + async listMcpServers(request: ListMcpServersRequest): Promise { + return this.call( + teamsServicePath('listMcpServers'), + ListMcpServersRequestSchema, + request, + 'listMcpServers', + ); + } + + async createMcpServer(request: CreateMcpServerRequest): Promise { + const response = await this.call( + teamsServicePath('createMcpServer'), + CreateMcpServerRequestSchema, + request, + 'createMcpServer', + ); + return this.requireResponseField(response.mcpServer, 'mcp_server'); + } + + async getMcpServer(request: GetMcpServerRequest): Promise { + const response = await this.call( + teamsServicePath('getMcpServer'), + GetMcpServerRequestSchema, + request, + 'getMcpServer', + ); + return this.requireResponseField(response.mcpServer, 'mcp_server'); + } + + async updateMcpServer(request: UpdateMcpServerRequest): Promise { + const response = await this.call( + teamsServicePath('updateMcpServer'), + UpdateMcpServerRequestSchema, + request, + 'updateMcpServer', + ); + return this.requireResponseField(response.mcpServer, 'mcp_server'); + } + + async deleteMcpServer(request: DeleteMcpServerRequest): Promise { + await this.call( + teamsServicePath('deleteMcpServer'), + DeleteMcpServerRequestSchema, + request, + 'deleteMcpServer', + ); + } + + async listWorkspaceConfigurations( + request: ListWorkspaceConfigurationsRequest, + ): Promise { + return this.call( + teamsServicePath('listWorkspaceConfigurations'), + ListWorkspaceConfigurationsRequestSchema, + request, + 'listWorkspaceConfigurations', + ); + } + + async createWorkspaceConfiguration( + request: CreateWorkspaceConfigurationRequest, + ): Promise { + const response = await this.call< + CreateWorkspaceConfigurationRequest, + CreateWorkspaceConfigurationResponse + >( + teamsServicePath('createWorkspaceConfiguration'), + CreateWorkspaceConfigurationRequestSchema, + request, + 'createWorkspaceConfiguration', + ); + return this.requireResponseField(response.workspaceConfiguration, 'workspace_configuration'); + } + + async getWorkspaceConfiguration( + request: GetWorkspaceConfigurationRequest, + ): Promise { + const response = await this.call( + teamsServicePath('getWorkspaceConfiguration'), + GetWorkspaceConfigurationRequestSchema, + request, + 'getWorkspaceConfiguration', + ); + return this.requireResponseField(response.workspaceConfiguration, 'workspace_configuration'); + } + + async updateWorkspaceConfiguration( + request: UpdateWorkspaceConfigurationRequest, + ): Promise { + const response = await this.call< + UpdateWorkspaceConfigurationRequest, + UpdateWorkspaceConfigurationResponse + >( + teamsServicePath('updateWorkspaceConfiguration'), + UpdateWorkspaceConfigurationRequestSchema, + request, + 'updateWorkspaceConfiguration', + ); + return this.requireResponseField(response.workspaceConfiguration, 'workspace_configuration'); + } + + async deleteWorkspaceConfiguration(request: DeleteWorkspaceConfigurationRequest): Promise { + await this.call( + teamsServicePath('deleteWorkspaceConfiguration'), + DeleteWorkspaceConfigurationRequestSchema, + request, + 'deleteWorkspaceConfiguration', + ); + } + + async listMemoryBuckets(request: ListMemoryBucketsRequest): Promise { + return this.call( + teamsServicePath('listMemoryBuckets'), + ListMemoryBucketsRequestSchema, + request, + 'listMemoryBuckets', + ); + } + + async createMemoryBucket(request: CreateMemoryBucketRequest): Promise { + const response = await this.call( + teamsServicePath('createMemoryBucket'), + CreateMemoryBucketRequestSchema, + request, + 'createMemoryBucket', + ); + return this.requireResponseField(response.memoryBucket, 'memory_bucket'); + } + + async getMemoryBucket(request: GetMemoryBucketRequest): Promise { + const response = await this.call( + teamsServicePath('getMemoryBucket'), + GetMemoryBucketRequestSchema, + request, + 'getMemoryBucket', + ); + return this.requireResponseField(response.memoryBucket, 'memory_bucket'); + } + + async updateMemoryBucket(request: UpdateMemoryBucketRequest): Promise { + const response = await this.call( + teamsServicePath('updateMemoryBucket'), + UpdateMemoryBucketRequestSchema, + request, + 'updateMemoryBucket', + ); + return this.requireResponseField(response.memoryBucket, 'memory_bucket'); + } + + async deleteMemoryBucket(request: DeleteMemoryBucketRequest): Promise { + await this.call( + teamsServicePath('deleteMemoryBucket'), + DeleteMemoryBucketRequestSchema, + request, + 'deleteMemoryBucket', + ); + } + + 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, + request, + 'listAttachments', + ); + } + + async createAttachment(request: CreateAttachmentRequest): Promise { + const response = await this.call( + teamsServicePath('createAttachment'), + CreateAttachmentRequestSchema, + request, + 'createAttachment', + ); + return this.requireResponseField(response.attachment, 'attachment'); + } + + async deleteAttachment(request: DeleteAttachmentRequest): Promise { + await this.call( + teamsServicePath('deleteAttachment'), + DeleteAttachmentRequestSchema, + request, + 'deleteAttachment', + ); + } + + 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, + request: Req, + 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, fn, timeoutMs); + } + + private unary( + path: string, + request: Request, + invoke: (request: Request, options?: CallOptions) => Promise, + timeoutMs?: number, + ): Promise { + const callOptions = this.buildCallOptions(timeoutMs); + 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 { timeoutMs: timeout }; + } + + 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 === Code.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: Code): HttpStatus { + switch (grpcCode) { + case Code.InvalidArgument: + return HttpStatus.BAD_REQUEST; + case Code.Unauthenticated: + return HttpStatus.UNAUTHORIZED; + case Code.PermissionDenied: + return HttpStatus.FORBIDDEN; + case Code.NotFound: + return HttpStatus.NOT_FOUND; + case Code.Aborted: + case Code.AlreadyExists: + return HttpStatus.CONFLICT; + case Code.FailedPrecondition: + return HttpStatus.PRECONDITION_FAILED; + case Code.ResourceExhausted: + return HttpStatus.TOO_MANY_REQUESTS; + case Code.Unimplemented: + return HttpStatus.NOT_IMPLEMENTED; + case Code.Internal: + case Code.DataLoss: + return HttpStatus.INTERNAL_SERVER_ERROR; + case Code.Unavailable: + return HttpStatus.SERVICE_UNAVAILABLE; + case Code.DeadlineExceeded: + return HttpStatus.GATEWAY_TIMEOUT; + case Code.OutOfRange: + return HttpStatus.BAD_REQUEST; + case Code.Canceled: + return 499 as HttpStatus; + default: + return HttpStatus.BAD_GATEWAY; + } + } + + private grpcStatusToErrorCode(grpcCode: Code): string { + switch (grpcCode) { + case Code.InvalidArgument: + return 'teams_invalid_argument'; + case Code.Unauthenticated: + return 'teams_unauthenticated'; + case Code.PermissionDenied: + return 'teams_forbidden'; + case Code.NotFound: + return 'teams_not_found'; + case Code.Aborted: + case Code.AlreadyExists: + return 'teams_conflict'; + case Code.FailedPrecondition: + return 'teams_failed_precondition'; + case Code.ResourceExhausted: + return 'teams_resource_exhausted'; + case Code.Unimplemented: + return 'teams_unimplemented'; + case Code.Internal: + return 'teams_internal_error'; + case Code.DataLoss: + return 'teams_data_loss'; + case Code.Unavailable: + return 'teams_unavailable'; + case Code.DeadlineExceeded: + return 'teams_timeout'; + case Code.Canceled: + return 'teams_cancelled'; + default: + return 'teams_grpc_error'; + } + } + + 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/packages/platform-server/src/teams/teamsGrpc.pagination.ts b/packages/platform-server/src/teams/teamsGrpc.pagination.ts new file mode 100644 index 000000000..22df4bbc9 --- /dev/null +++ b/packages/platform-server/src/teams/teamsGrpc.pagination.ts @@ -0,0 +1,33 @@ +export const DEFAULT_PAGE_SIZE = 100; +export const MAX_PAGES = 50; + +export type PaginatedResponse = { + items: T[]; + nextPageToken?: string | null; +}; + +export const listAllPages = async ( + fetchPage: (pageToken: string | undefined, pageSize: number) => Promise>, +): Promise => { + const items: T[] = []; + let pageToken: string | undefined = undefined; + for (let i = 0; i < MAX_PAGES; i += 1) { + const response = await fetchPage(pageToken, DEFAULT_PAGE_SIZE); + const pageItems = response.items; + items.push(...pageItems); + const nextToken = readString(response.nextPageToken ?? undefined); + if (!nextToken) break; + if (nextToken === pageToken) { + throw new Error('teams_pagination_duplicate_token'); + } + if (pageItems.length === 0) break; + pageToken = nextToken; + } + 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; +}; 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/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-server/src/types/picomatch.d.ts b/packages/platform-server/src/types/picomatch.d.ts new file mode 100644 index 000000000..0851e1bff --- /dev/null +++ b/packages/platform-server/src/types/picomatch.d.ts @@ -0,0 +1,9 @@ +declare module 'picomatch' { + export type PicomatchOptions = Record; + export type Matcher = (input: string) => boolean; + + export default function picomatch( + pattern: string | readonly string[], + options?: PicomatchOptions, + ): Matcher; +} diff --git a/packages/platform-server/src/utils/reference-resolver.service.ts b/packages/platform-server/src/utils/reference-resolver.service.ts index d1412ab05..6b9e92736 100644 --- a/packages/platform-server/src/utils/reference-resolver.service.ts +++ b/packages/platform-server/src/utils/reference-resolver.service.ts @@ -1,27 +1,20 @@ +import { create } from '@bufbuild/protobuf'; import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; import type { ResolveOptions, ResolveResult, Providers } from './references'; import { resolveReferences, ResolveError } from './references'; +import { ResolveVariableRequestSchema } from '../proto/gen/agynio/api/teams/v1/teams_pb'; 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, basePath?: string, ): Providers { @@ -35,14 +28,17 @@ 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 request = create(ResolveVariableRequestSchema, { key: ref.name }); + const response = await this.teamsClient.resolveVariable(request); + if (!response?.found) return undefined; + const value = response.value?.trim?.() ?? response.value; + return value && value.length > 0 ? value : undefined; }; return { secret, @@ -53,10 +49,10 @@ export class ReferenceResolverService { async resolve( input: T, - opts?: ResolveOptions & { graphName?: string; providers?: Partial }, + opts?: ResolveOptions & { providers?: Partial }, ): Promise> { - const { graphName, providers: overrides, ...options } = opts || {}; - const providers = this.buildProviders(graphName, overrides, options.basePath); + const { providers: overrides, ...options } = opts || {}; + const providers = this.buildProviders(overrides, options.basePath); return resolveReferences(input, providers, options); } } diff --git a/packages/platform-server/tools/graph-ref-migrate/README.md b/packages/platform-server/tools/graph-ref-migrate/README.md deleted file mode 100644 index 92d91553b..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# graph-ref-migrate - -CLI for normalizing legacy graph reference objects to the canonical schema. - -## Usage - -```bash -pnpm --filter @agyn/platform-server exec graph-ref-migrate --input /path/to/graph/**/*.json --dry-run -``` - -Run with `--write` to persist changes. Dry-run is the default when neither -`--dry-run` nor `--write` is supplied. - -### Options - -| Flag | Description | -| --- | --- | -| `--input ` | Required file/directory/glob selection. Directories expand to `**/*.json`. | -| `--include ` | Optional additional glob filter (repeatable). | -| `--exclude ` | Optional glob patterns to skip (repeatable). | -| `--dry-run` / `--write` | Preview vs. persist (mutually exclusive). | -| `--backup` / `--no-backup` | Create timestamped `.backup-…` copies before writes (default `true`). | -| `--default-mount ` | Canonical vault mount name when legacy refs omit it (default `secret`). | -| `--known-mounts ` | Comma-separated canonical mounts to treat as explicit (default `secret`). | -| `--validate-schema` / `--no-validate-schema` | Enable canonical ref + node sanity checks (default `true`). | -| `--verbose` | Emit per-reference conversion details. | - -The tool recursively traverses node `config` and `state` objects, replaces -legacy reference shapes, and preserves JSON indentation and newline style. On -write, updates are applied atomically with temporary files and optional -backups. - -### Examples - -Preview conversions beneath a git-backed graph working tree: - -```bash -pnpm --filter @agyn/platform-server exec graph-ref-migrate \ - --input ./graphs/main \ - --include 'nodes/**/*.json' \ - --dry-run --verbose -``` - -Apply migrations, disable backups, and skip schema validation (when running in -an isolated clone): - -```bash -pnpm --filter @agyn/platform-server exec graph-ref-migrate \ - --input ./graphs/main \ - --write --no-backup --no-validate-schema -``` - -If a file cannot be migrated (e.g., invalid legacy path), the tool records the -error, leaves the original file untouched, and exits with a non-zero status. - -> Legacy vault references are parsed according to the following rules: -> - Values with three or more segments map to `mount/path/key` directly. -> - Two-segment values use the configured default mount **unless** the first -> segment is in `--known-mounts`, in which case they are flagged as errors -> (to avoid misinterpreting `mount/key` pairs without a path). -> - Strings starting with `/` or containing fewer than two segments are invalid. diff --git a/packages/platform-server/tools/graph-ref-migrate/bin.mjs b/packages/platform-server/tools/graph-ref-migrate/bin.mjs deleted file mode 100644 index 0f89420fe..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/bin.mjs +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -import 'tsx/esm'; - -const { main } = await import('./cli.ts'); - -const code = await main(); - -process.exit(code); diff --git a/packages/platform-server/tools/graph-ref-migrate/cli.ts b/packages/platform-server/tools/graph-ref-migrate/cli.ts deleted file mode 100644 index 31f2afebe..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/cli.ts +++ /dev/null @@ -1,74 +0,0 @@ -import process from 'node:process'; -import type { Writable } from 'node:stream'; - -import { CliError, parseCliOptions } from './options'; -import { runMigration } from './run'; -import type { Logger, MigrationOptions, MigrationSummary } from './types'; - -type CliIO = { - stdout: Writable; - stderr: Writable; -}; - -const defaultIO: CliIO = { - stdout: process.stdout, - stderr: process.stderr, -}; - -const createLogger = (io: CliIO, verbose: boolean): Logger => ({ - info: (message) => { - io.stdout.write(`${message}\n`); - }, - warn: (message) => { - io.stderr.write(`WARNING: ${message}\n`); - }, - error: (message) => { - io.stderr.write(`ERROR: ${message}\n`); - }, - verbose: (message) => { - if (verbose) io.stdout.write(`${message}\n`); - }, -}); - -const printSummary = (summary: MigrationSummary, logger: Logger): void => { - const totalFiles = summary.files.length; - const filesWithChanges = summary.files.filter((file) => file.changed).length; - const appliedChanges = summary.mode === 'write' ? summary.files.filter((file) => file.changed && !file.skipped).length : 0; - const skipped = summary.files.filter((file) => file.skipped).length; - const totalConversions = summary.files.reduce((acc, file) => acc + file.conversions.length, 0); - const totalErrors = summary.files.reduce((acc, file) => acc + file.errors.length, 0); - - logger.info( - `Summary: ${totalFiles} file(s), ${filesWithChanges} with changes, ${appliedChanges} applied, ${skipped} skipped, ${totalConversions} conversions, ${totalErrors} error(s)`, - ); -}; - -const hasFailures = (summary: MigrationSummary): boolean => summary.files.some((file) => file.errors.length > 0); - -export const main = async (argv: readonly string[] = process.argv.slice(2), io: CliIO = defaultIO): Promise => { - let options: MigrationOptions; - try { - options = parseCliOptions(argv, process.cwd()); - } catch (error) { - const message = error instanceof CliError ? error.message : (error as Error).message; - io.stderr.write(`ERROR: ${message}\n`); - return 1; - } - - const logger = createLogger(io, options.verbose); - - try { - const summary = await runMigration(options, logger); - printSummary(summary, logger); - if (hasFailures(summary)) { - logger.error('Migration completed with errors'); - return 1; - } - logger.info('Migration completed successfully'); - return 0; - } catch (error) { - const message = error instanceof CliError ? error.message : (error as Error).message; - logger.error(message); - return 1; - } -}; diff --git a/packages/platform-server/tools/graph-ref-migrate/options.ts b/packages/platform-server/tools/graph-ref-migrate/options.ts deleted file mode 100644 index 1603f5b38..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/options.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { parseArgs } from 'node:util'; -import path from 'node:path'; - -import type { MigrationMode, MigrationOptions } from './types'; - -export class CliError extends Error { - constructor(message: string) { - super(message); - this.name = 'CliError'; - } -} - -type RawOptionValues = { - input?: string; - include?: string[] | string; - exclude?: string[] | string; - 'dry-run'?: boolean; - write?: boolean; - backup?: boolean; - 'default-mount'?: string; - 'known-mounts'?: string; - 'validate-schema'?: boolean; - verbose?: boolean; -}; - -const toArray = (value: string[] | string | undefined): string[] => { - if (value === undefined) return []; - return Array.isArray(value) ? value : [value]; -}; - -const sanitizePatterns = (patterns: string[], flag: '--include' | '--exclude'): string[] => { - const sanitized: string[] = []; - for (const raw of patterns) { - const value = raw.trim(); - if (!value) throw new CliError(`${flag} cannot be empty`); - sanitized.push(value); - } - return sanitized; -}; - -const resolveMode = (values: RawOptionValues): MigrationMode => { - const dryRun = values['dry-run']; - const write = values.write; - if (dryRun && write) throw new CliError('Cannot use --dry-run and --write together'); - if (write) return 'write'; - return 'dry-run'; -}; - -const normalizeArgv = (argv: readonly string[]): { - args: string[]; - backupNegated: boolean; - validateSchemaNegated: boolean; -} => { - let backupNegated = false; - let validateSchemaNegated = false; - const args: string[] = []; - - for (const arg of argv) { - if (arg === '--no-backup') { - backupNegated = true; - continue; - } - if (arg === '--no-validate-schema') { - validateSchemaNegated = true; - continue; - } - args.push(arg); - } - - return { args, backupNegated, validateSchemaNegated }; -}; - -export const parseCliOptions = (argv: readonly string[], cwd: string): MigrationOptions => { - const normalized = normalizeArgv(argv); - const { values, positionals } = parseArgs({ - args: normalized.args, - options: { - input: { type: 'string' }, - include: { type: 'string', multiple: true }, - exclude: { type: 'string', multiple: true }, - 'dry-run': { type: 'boolean' }, - write: { type: 'boolean' }, - backup: { type: 'boolean' }, - 'default-mount': { type: 'string' }, - 'known-mounts': { type: 'string' }, - 'validate-schema': { type: 'boolean' }, - verbose: { type: 'boolean' }, - }, - allowPositionals: true, - }); - - if (positionals.length > 0) throw new CliError(`Unexpected arguments: ${positionals.join(' ')}`); - - const input = (values.input ?? '').trim(); - if (!input) throw new CliError('--input is required'); - - const includes = sanitizePatterns(toArray(values.include), '--include'); - const excludes = sanitizePatterns(toArray(values.exclude), '--exclude'); - - const defaultMount = (values['default-mount'] ?? 'secret').trim(); - if (!defaultMount) throw new CliError('--default-mount cannot be empty'); - - const knownMountsInput = values['known-mounts'] ?? 'secret'; - const knownMounts = knownMountsInput - .split(',') - .map((mount) => mount.trim()) - .filter((mount) => mount.length > 0); - if (knownMounts.length === 0) throw new CliError('--known-mounts must include at least one mount'); - - const dedupedKnownMounts = Array.from(new Set(knownMounts)); - - const mode = resolveMode(values); - - if (normalized.backupNegated && values.backup === true) - throw new CliError('Cannot use --backup and --no-backup together'); - if (normalized.validateSchemaNegated && values['validate-schema'] === true) - throw new CliError('Cannot use --validate-schema and --no-validate-schema together'); - - const backup = normalized.backupNegated ? false : values.backup ?? true; - const validateSchema = normalized.validateSchemaNegated ? false : values['validate-schema'] ?? true; - const verbose = values.verbose ?? false; - - return { - input: path.resolve(cwd, input), - includes, - excludes, - mode, - backup, - defaultMount, - knownMounts: dedupedKnownMounts, - validateSchema, - verbose, - cwd, - }; -}; diff --git a/packages/platform-server/tools/graph-ref-migrate/run.ts b/packages/platform-server/tools/graph-ref-migrate/run.ts deleted file mode 100644 index 162d1622d..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/run.ts +++ /dev/null @@ -1,200 +0,0 @@ -import fs from 'node:fs/promises'; -import { constants as fsConstants } from 'node:fs'; -import path from 'node:path'; - -import fg from 'fast-glob'; -import picomatch from 'picomatch'; - -import { migrateValue } from './transform'; -import type { FileOutcome, Logger, MigrationOptions, MigrationSummary } from './types'; - -const detectIndent = (raw: string): string => { - const lines = raw.split(/\r?\n/); - for (const line of lines) { - const match = line.match(/^(\s+)"/); - if (match) return match[1]; - } - return ' '; -}; - -const detectNewline = (raw: string): '\n' | '\r\n' => (raw.includes('\r\n') ? '\r\n' : '\n'); - -const formatJson = (value: unknown, indent: string, newline: '\n' | '\r\n', hadTrailingNewline: boolean): string => { - const json = JSON.stringify(value, null, indent); - const normalized = newline === '\n' ? json : json.replace(/\n/g, newline); - if (hadTrailingNewline) return normalized.endsWith(newline) ? normalized : normalized + newline; - return normalized.endsWith(newline) ? normalized.slice(0, -newline.length) : normalized; -}; - -const randomSuffix = (): string => Math.random().toString(16).slice(2, 10); - -const atomicWriteFile = async (targetPath: string, contents: string): Promise => { - const dir = path.dirname(targetPath); - const base = path.basename(targetPath); - const tempPath = path.join(dir, `.${base}.tmp-${randomSuffix()}`); - await fs.writeFile(tempPath, contents, 'utf8'); - await fs.rename(tempPath, targetPath); -}; - -const createBackup = async (targetPath: string, timestamp: string): Promise => { - const backupPath = `${targetPath}.backup-${timestamp}`; - await fs.copyFile(targetPath, backupPath, fsConstants.COPYFILE_EXCL).catch(async (error: unknown) => { - if ((error as { code?: string }).code === 'EEXIST') return; - throw error; - }); - return backupPath; -}; - -const writeFileWithOptionalBackup = async (params: { - filePath: string; - contents: string; - backup: boolean; - timestamp: string; -}): Promise => { - const { filePath, contents, backup, timestamp } = params; - if (backup) await createBackup(filePath, timestamp); - await atomicWriteFile(filePath, contents); -}; - -const persistChanges = async (params: { - filePath: string; - raw: string; - value: unknown; - backup: boolean; - timestamp: string; - logger: Logger; - relativePath: string; -}): Promise => { - const { filePath, raw, value, backup, timestamp, logger, relativePath } = params; - const indent = detectIndent(raw); - const newline = detectNewline(raw); - const hadTrailingNewline = raw.endsWith(newline); - const next = formatJson(value, indent, newline, hadTrailingNewline); - if (next === raw) { - logger.verbose(`No serialized diff for ${relativePath}`); - return; - } - await writeFileWithOptionalBackup({ filePath, contents: next, backup, timestamp }); - logger.info(`Updated ${relativePath}`); -}; - -const resolveFiles = async (options: MigrationOptions): Promise => { - const { input, cwd } = options; - const result: string[] = []; - try { - const stat = await fs.stat(input); - if (stat.isDirectory()) { - const pattern = path.join(input, '**/*.json'); - const matches = await fg(pattern, { dot: false, onlyFiles: true, absolute: true, followSymbolicLinks: false }); - result.push(...matches); - return result; - } - if (stat.isFile()) { - result.push(input); - return result; - } - } catch { - // Treat as glob below - } - - const matches = await fg(input, { dot: false, onlyFiles: true, absolute: true, followSymbolicLinks: false, cwd }); - result.push(...matches); - return result; -}; - -const applyIncludesExcludes = (files: string[], options: MigrationOptions): string[] => { - if (files.length === 0) return files; - const relative = files.map((file) => ({ absolute: file, relative: path.relative(options.cwd, file) })); - - const includeMatchers = options.includes.map((pattern) => picomatch(pattern)); - const excludeMatchers = options.excludes.map((pattern) => picomatch(pattern)); - - return relative - .filter(({ relative: rel }) => { - if (includeMatchers.length > 0 && !includeMatchers.some((match) => match(rel))) return false; - if (excludeMatchers.some((match) => match(rel))) return false; - return true; - }) - .map(({ absolute }) => absolute); -}; - -export const runMigration = async (options: MigrationOptions, logger: Logger): Promise => { - const files = applyIncludesExcludes(await resolveFiles(options), options); - if (files.length === 0) throw new Error('No files matched the provided input patterns'); - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const results: FileOutcome[] = []; - const knownMounts = new Set(options.knownMounts); - - for (const filePath of files) { - const relativePath = path.relative(options.cwd, filePath); - logger.info(`Processing ${relativePath}`); - const outcome: FileOutcome = { path: filePath, changed: false, conversions: [], errors: [] }; - - let raw: string; - try { - raw = await fs.readFile(filePath, 'utf8'); - } catch (error) { - outcome.errors.push({ pointer: '/', message: `Failed to read file: ${(error as Error).message}` }); - outcome.skipped = true; - results.push(outcome); - logger.error(`Read failed for ${relativePath}`); - continue; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error) { - outcome.errors.push({ pointer: '/', message: `Invalid JSON: ${(error as Error).message}` }); - outcome.skipped = true; - results.push(outcome); - logger.error(`Invalid JSON in ${relativePath}`); - continue; - } - - const migrated = migrateValue( - parsed, - { defaultMount: options.defaultMount, knownMounts }, - { validate: options.validateSchema }, - ); - outcome.changed = migrated.changed; - outcome.conversions = migrated.conversions; - outcome.errors.push(...migrated.errors); - - if (outcome.conversions.length > 0) { - for (const conversion of outcome.conversions) { - const detail = conversion.usedDefaultMount ? ' (default mount applied)' : ''; - logger.verbose(`Converted ${conversion.legacy} -> ${conversion.kind} at ${conversion.pointer}${detail}`); - } - } - - if (outcome.errors.length > 0) { - outcome.skipped = true; - for (const error of outcome.errors) logger.error(`${error.pointer}: ${error.message}`); - } - - if (!outcome.changed || outcome.skipped) { - results.push(outcome); - continue; - } - - if (options.mode === 'write') { - await persistChanges({ - filePath, - raw, - value: migrated.value, - backup: options.backup, - timestamp, - logger, - relativePath, - }); - } else if (options.mode === 'dry-run') { - logger.info(`Would update ${relativePath}`); - } - - results.push(outcome); - } - - return { files: results, mode: options.mode }; -}; diff --git a/packages/platform-server/tools/graph-ref-migrate/transform.ts b/packages/platform-server/tools/graph-ref-migrate/transform.ts deleted file mode 100644 index bb67817ea..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/transform.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { SecretReferenceSchema, VariableReferenceSchema } from '../../src/utils/reference-schemas'; -import type { ConversionRecord, MigrationError, TransformOutcome } from './types'; - -type TransformContext = { - defaultMount: string; - knownMounts: ReadonlySet; -}; - -type Pointer = readonly (string | number)[]; - -type MigrateOptions = { - validate: boolean; -}; - -const isPlainObject = (value: unknown): value is Record => - !!value && typeof value === 'object' && !Array.isArray(value); - -const escapePointerSegment = (segment: string): string => segment.replace(/~/g, '~0').replace(/\//g, '~1'); - -const pointerToString = (pointer: Pointer): string => - pointer.length === 0 ? '/' : `/${pointer.map((part) => escapePointerSegment(String(part))).join('/')}`; - -const isCanonicalVaultRef = (value: unknown): boolean => { - if (!isPlainObject(value)) return false; - if ((value as { kind?: unknown }).kind !== 'vault') return false; - return SecretReferenceSchema.safeParse(value).success; -}; - -const isCanonicalVarRef = (value: unknown): boolean => { - if (!isPlainObject(value)) return false; - if ((value as { kind?: unknown }).kind !== 'var') return false; - return VariableReferenceSchema.safeParse(value).success; -}; - -type LegacyVaultRef = { - source: 'vault'; - value: string; -}; - -const isLegacyVaultRef = (value: unknown): value is LegacyVaultRef => - isPlainObject(value) && (value as { source?: unknown }).source === 'vault' && typeof (value as { value?: unknown }).value === 'string'; - -type LegacyEnvRef = { - source: 'env'; - envVar: string; - default?: string; -}; - -const isLegacyEnvRef = (value: unknown): value is LegacyEnvRef => - isPlainObject(value) && (value as { source?: unknown }).source === 'env' && typeof (value as { envVar?: unknown }).envVar === 'string'; - -type LegacyStaticRef = { - source: 'static'; - value: unknown; -}; - -const isLegacyStaticRef = (value: unknown): value is LegacyStaticRef => - isPlainObject(value) && (value as { source?: unknown }).source === 'static' && Object.prototype.hasOwnProperty.call(value, 'value'); - -const joinPathSegments = (segments: string[]): string => segments.join('/'); - -const transformLegacyVaultRef = (input: LegacyVaultRef, ctx: TransformContext, pointer: Pointer): TransformOutcome => { - const pointerStr = pointerToString(pointer); - const conversions: ConversionRecord[] = []; - const errors: MigrationError[] = []; - - const raw = input.value.trim(); - if (!raw) { - errors.push({ pointer: pointerStr, message: 'Legacy vault reference is empty' }); - return { value: input, changed: false, conversions, errors }; - } - - if (raw.startsWith('/')) { - errors.push({ pointer: pointerStr, message: 'Legacy vault reference cannot start with "/"' }); - return { value: input, changed: false, conversions, errors }; - } - - const segments = raw.split('/').filter((segment) => segment.length > 0); - if (segments.length < 2) { - errors.push({ pointer: pointerStr, message: 'Legacy vault reference must include mount, path, and key segments' }); - return { value: input, changed: false, conversions, errors }; - } - - let mount: string; - let pathSegments: string[]; - let key: string | undefined; - let usedDefaultMount = false; - - if (segments.length === 2) { - const [first, second] = segments; - if (ctx.knownMounts.has(first)) { - errors.push({ pointer: pointerStr, message: 'Legacy vault reference missing path segment between mount and key' }); - return { value: input, changed: false, conversions, errors }; - } - mount = ctx.defaultMount; - pathSegments = [first]; - key = second; - usedDefaultMount = true; - } else { - mount = segments[0]; - key = segments[segments.length - 1]; - pathSegments = segments.slice(1, -1); - } - - if (!key) { - errors.push({ pointer: pointerStr, message: 'Legacy vault reference missing key segment' }); - return { value: input, changed: false, conversions, errors }; - } - if (pathSegments.length === 0) { - errors.push({ pointer: pointerStr, message: 'Legacy vault reference missing path segment' }); - return { value: input, changed: false, conversions, errors }; - } - - const nextValue = { - kind: 'vault' as const, - mount, - path: joinPathSegments(pathSegments), - key, - }; - - if (!SecretReferenceSchema.safeParse(nextValue).success) { - errors.push({ pointer: pointerStr, message: 'Canonical vault reference validation failed' }); - return { value: input, changed: false, conversions, errors }; - } - - conversions.push({ - pointer: pointerStr, - kind: 'vault', - legacy: 'vault', - ...(usedDefaultMount ? { usedDefaultMount: true } : {}), - }); - return { value: nextValue, changed: true, conversions, errors }; -}; - -const transformValue = (input: unknown, ctx: TransformContext, pointer: Pointer): TransformOutcome => { - const conversions: ConversionRecord[] = []; - const errors: MigrationError[] = []; - - if (Array.isArray(input)) { - let changed = false; - const next: unknown[] = new Array(input.length); - input.forEach((item, index) => { - const child = transformValue(item, ctx, [...pointer, index]); - if (child.changed) changed = true; - conversions.push(...child.conversions); - errors.push(...child.errors); - next[index] = child.value; - }); - return { value: changed ? next : input, changed, conversions, errors }; - } - - if (isPlainObject(input)) { - if (isCanonicalVaultRef(input) || isCanonicalVarRef(input)) { - return { value: input, changed: false, conversions, errors }; - } - - if (isLegacyVaultRef(input)) return transformLegacyVaultRef(input, ctx, pointer); - - if (isLegacyEnvRef(input)) { - const envVar = input.envVar.trim(); - if (!envVar) { - errors.push({ pointer: pointerToString(pointer), message: 'Legacy env reference missing envVar' }); - return { value: input, changed: false, conversions, errors }; - } - const nextValue = { - kind: 'var' as const, - name: envVar, - ...(input.default !== undefined ? { default: input.default } : {}), - }; - - if (!VariableReferenceSchema.safeParse(nextValue).success) { - errors.push({ pointer: pointerToString(pointer), message: 'Canonical variable reference validation failed' }); - return { value: input, changed: false, conversions, errors }; - } - - conversions.push({ pointer: pointerToString(pointer), kind: 'var', legacy: 'env' }); - return { value: nextValue, changed: true, conversions, errors }; - } - - if (isLegacyStaticRef(input)) { - const primitive = input.value; - if (primitive === null || ['string', 'number', 'boolean'].includes(typeof primitive)) { - conversions.push({ pointer: pointerToString(pointer), kind: 'static', legacy: 'static' }); - return { value: primitive, changed: true, conversions, errors }; - } - errors.push({ pointer: pointerToString(pointer), message: 'Legacy static reference must resolve to a primitive value' }); - return { value: input, changed: false, conversions, errors }; - } - - let changed = false; - const result: Record = {}; - for (const [key, value] of Object.entries(input)) { - const child = transformValue(value, ctx, [...pointer, key]); - if (child.changed) changed = true; - conversions.push(...child.conversions); - errors.push(...child.errors); - result[key] = child.value; - } - return { value: changed ? result : input, changed, conversions, errors }; - } - - return { value: input, changed: false, conversions, errors }; -}; - -const collectPostTransformErrors = (value: unknown, pointer: Pointer, errors: MigrationError[]): void => { - if (Array.isArray(value)) { - value.forEach((item, index) => collectPostTransformErrors(item, [...pointer, index], errors)); - return; - } - - if (!isPlainObject(value)) return; - - const pointerStr = pointerToString(pointer); - if (isLegacyVaultRef(value) || isLegacyEnvRef(value) || isLegacyStaticRef(value)) { - errors.push({ pointer: pointerStr, message: 'Legacy reference remains after migration' }); - return; - } - - if ((value as { kind?: unknown }).kind === 'vault') { - if (!SecretReferenceSchema.safeParse(value).success) errors.push({ pointer: pointerStr, message: 'Invalid canonical vault reference detected' }); - } else if ((value as { kind?: unknown }).kind === 'var') { - if (!VariableReferenceSchema.safeParse(value).success) errors.push({ pointer: pointerStr, message: 'Invalid canonical variable reference detected' }); - } - - const isNodeLike = typeof (value as { id?: unknown }).id === 'string' && typeof (value as { template?: unknown }).template === 'string'; - if (isNodeLike) { - if (Object.prototype.hasOwnProperty.call(value, 'config')) { - const config = (value as { config?: unknown }).config; - if (config !== undefined && !isPlainObject(config)) { - errors.push({ - pointer: pointerToString([...pointer, 'config']), - message: 'PersistedGraphNode.config must be an object when provided', - }); - } - } - if (Object.prototype.hasOwnProperty.call(value, 'state')) { - const state = (value as { state?: unknown }).state; - if (state !== undefined && !isPlainObject(state)) { - errors.push({ - pointer: pointerToString([...pointer, 'state']), - message: 'PersistedGraphNode.state must be an object when provided', - }); - } - } - } - - for (const [key, child] of Object.entries(value)) collectPostTransformErrors(child, [...pointer, key], errors); -}; - -export const migrateValue = (input: unknown, ctx: TransformContext, opts: MigrateOptions): TransformOutcome => { - const transformed = transformValue(input, ctx, []); - const validationErrors: MigrationError[] = []; - if (opts.validate) collectPostTransformErrors(transformed.value, [], validationErrors); - return { - value: transformed.value, - changed: transformed.changed, - conversions: transformed.conversions, - errors: [...transformed.errors, ...validationErrors], - }; -}; diff --git a/packages/platform-server/tools/graph-ref-migrate/types.ts b/packages/platform-server/tools/graph-ref-migrate/types.ts deleted file mode 100644 index ede1f95d2..000000000 --- a/packages/platform-server/tools/graph-ref-migrate/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type MigrationMode = 'dry-run' | 'write'; - -export type MigrationOptions = { - input: string; - includes: string[]; - excludes: string[]; - mode: MigrationMode; - backup: boolean; - defaultMount: string; - knownMounts: string[]; - validateSchema: boolean; - verbose: boolean; - cwd: string; -}; - -export type Logger = { - info(message: string): void; - warn(message: string): void; - error(message: string): void; - verbose(message: string): void; -}; - -export type ConversionKind = 'vault' | 'var' | 'static'; - -export type LegacyKind = 'vault' | 'env' | 'static'; - -export type ConversionRecord = { - pointer: string; - kind: ConversionKind; - legacy: LegacyKind; - usedDefaultMount?: boolean; -}; - -export type MigrationError = { - pointer: string; - message: string; -}; - -export type TransformOutcome = { - value: unknown; - changed: boolean; - conversions: ConversionRecord[]; - errors: MigrationError[]; -}; - -export type FileOutcome = { - path: string; - changed: boolean; - conversions: ConversionRecord[]; - errors: MigrationError[]; - skipped?: boolean; -}; - -export type MigrationSummary = { - files: FileOutcome[]; - mode: MigrationMode; -}; 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__/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/__tests__/integration/graph.flows.test.tsx b/packages/platform-ui/__tests__/integration/graph.flows.test.tsx deleted file mode 100644 index f426b13db..000000000 --- a/packages/platform-ui/__tests__/integration/graph.flows.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { http as _http, HttpResponse as _HttpResponse } from 'msw'; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { NodeDetailsPanel } from '../../src/components/graph'; -import { disposeGraphSocket, emitNodeStatus, server, startSocketTestServer, stopSocketTestServer, TestProviders } from './testUtils'; - -beforeAll(async () => { - await startSocketTestServer(); - server.listen(); -}); -afterEach(() => { - server.resetHandlers(); - disposeGraphSocket(); -}); -afterAll(async () => { - server.close(); - await stopSocketTestServer(); -}); - -describe('Integration flows: Node actions, dynamic/static config', () => { - it('Provision flow with optimistic UI and socket reconcile', async () => { - render( - - - , - ); - - // initial state not_ready - await waitFor(() => expect(screen.getByText('not_ready')).toBeInTheDocument()); - - // click provision -> optimistic provisioning - fireEvent.click(screen.getByText('Provision')); - await waitFor(() => expect(screen.getByText('provisioning')).toBeInTheDocument()); - - // socket emits ready - emitNodeStatus({ nodeId: 'n1', provisionStatus: { state: 'ready' } }); - await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument()); - - // buttons adjust - expect(screen.getByText('Deprovision')).not.toBeDisabled(); - }); - - // Pause/Resume removed; no test for paused reconcile - - // Schema-driven forms removed; covered by custom views tests elsewhere -}); -vi.setConfig({ testTimeout: 30000 }); 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/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/graph.ts b/packages/platform-ui/src/api/hooks/graph.ts index 90d4e5907..37b9c47fc 100644 --- a/packages/platform-ui/src/api/hooks/graph.ts +++ b/packages/platform-ui/src/api/hooks/graph.ts @@ -6,7 +6,5 @@ export { useReminderCount, useNodeAction, useDynamicConfig, - useSaveGraph, - useMcpNodeState, + useMcpTools, } from '@/lib/graph/hooks'; - 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/graph.ts b/packages/platform-ui/src/api/modules/graph.ts index e74736721..6490fabd2 100644 --- a/packages/platform-ui/src/api/modules/graph.ts +++ b/packages/platform-ui/src/api/modules/graph.ts @@ -1,5 +1,5 @@ import { http } from '@/api/http'; -import type { TemplateSchema, NodeStatus, PersistedGraphUpsertRequestUI, ReminderDTO } from '@/api/types/graph'; +import type { TemplateSchema, NodeStatus, ReminderDTO } from '@/api/types/graph'; import type { PersistedGraph, PersistedGraphNode } from '@agyn/shared'; import { collectVaultRefs } from '@/lib/vault/collect'; import { parseVaultRef, isValidVaultRef } from '@/lib/vault/parse'; @@ -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 }), + discoverTools: (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> => { @@ -81,14 +82,8 @@ export const graph = { postNodeAction: (nodeId: string, action: 'provision' | 'deprovision') => http.post(`/api/graph/nodes/${encodeURIComponent(nodeId)}/actions`, { action }), - // Full graph - saveFullGraph: (g: PersistedGraphUpsertRequestUI) => - http.post(`/api/graph`, g), - getFullGraph: () => http.get(`/api/graph`), }; -export type { PersistedGraphUpsertRequestUI }; - // Secrets helpers/types (authoritative definitions for Settings/Secrets) export type SecretKey = { mount: string; path: string; key: string }; export type SecretEntry = SecretKey & { required: boolean; present: boolean }; 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..1b0795bb7 --- /dev/null +++ b/packages/platform-ui/src/api/modules/teamApi.ts @@ -0,0 +1,364 @@ +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 = { + page?: number; + perPage?: number; + q?: string; +}; + +type PageInfo = { + 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 readRequiredString(record: Record, key: string, label: string): string { + const value = readString(record[key]); + if (!value) { + throw new Error(`Unexpected ${label} response`); + } + return value; +} + +function readRequiredRecord(record: Record, key: string, label: string): Record { + const value = record[key]; + if (!isRecord(value)) { + throw new Error(`Unexpected ${label} response`); + } + 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; +} + +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, 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 pageInfo = readPageInfo(record); + return { items: items.map((item) => parseItem(item)), ...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; + 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'), + }; +} + +async function listAllPages( + fetchPage: (params: TeamListParams) => Promise>, + params?: TeamListParams, +): Promise { + const items: T[] = []; + 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({ page, perPage, q }); + items.push(...response.items); + 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, parseAgent); +} + +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 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 { + 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, parseTool); +} + +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 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 { + 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, parseMcpServer); +} + +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 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 { + 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, parseWorkspaceConfiguration); +} + +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 updateWorkspaceConfiguration( + id: string, + payload: TeamWorkspaceConfigurationUpdateRequest, +): Promise { + const response = await http.patch(`${TEAM_API_PREFIX}/workspace-configurations/${id}`, payload); + return parseWorkspaceConfiguration(response); +} + +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, parseMemoryBucket); +} + +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 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 { + 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, parseAttachment); +} + +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 deleteAttachment(id: string): Promise { + await http.delete(`${TEAM_API_PREFIX}/attachments/${id}`); +} diff --git a/packages/platform-ui/src/api/types/graph.ts b/packages/platform-ui/src/api/types/graph.ts index 1791267e8..dcc114de5 100644 --- a/packages/platform-ui/src/api/types/graph.ts +++ b/packages/platform-ui/src/api/types/graph.ts @@ -25,13 +25,6 @@ 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 } - -export interface PersistedGraphUpsertRequestUI { - name?: string; - version?: number; - nodes: Array<{ id: string; position?: { x: number; y: number }; template: string; config?: Record }>; - edges: Array<{ source: string; sourceHandle?: string; target: string; targetHandle?: string }>; -} 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..97108c51f --- /dev/null +++ b/packages/platform-ui/src/api/types/team.ts @@ -0,0 +1,157 @@ +export type TeamAgentWhenBusy = 'wait' | 'injectAfterTools'; + +export type TeamAgentProcessBuffer = 'allTogether' | 'oneByOne'; + +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'; + +export type TeamAttachmentKind = + | 'agent_tool' + | 'agent_memoryBucket' + | 'agent_workspaceConfiguration' + | 'agent_mcpServer' + | 'mcpServer_workspaceConfiguration'; + +export interface TeamListResponse { + items: T[]; + page: number; + perPage: number; + total: number; +} + +export interface TeamAgent { + id: string; + createdAt: string; + updatedAt: string; + title?: string; + description?: string; + config: Record; +} + +export interface TeamTool { + id: string; + createdAt: string; + updatedAt: string; + type: TeamToolType; + name?: string; + description?: string; + config: Record; +} + +export interface TeamMcpServer { + id: string; + createdAt: string; + updatedAt: string; + title?: string; + description?: string; + config: Record; +} + +export interface TeamWorkspaceConfiguration { + id: string; + createdAt: string; + updatedAt: string; + title?: string; + description?: string; + config: Record; +} + +export interface TeamMemoryBucket { + id: string; + createdAt: string; + updatedAt: string; + title?: string; + description?: string; + config: Record; +} + +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; +} diff --git a/packages/platform-ui/src/components/EmptySelectionSidebar.tsx b/packages/platform-ui/src/components/EmptySelectionSidebar.tsx deleted file mode 100644 index 156407dad..000000000 --- a/packages/platform-ui/src/components/EmptySelectionSidebar.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import type { DragEvent } from 'react'; -import { Plus } from 'lucide-react'; -import Badge from './Badge'; - -export interface DraggableNodeItem { - id: string; - kind: 'Trigger' | 'Agent' | 'Tool' | 'MCP' | 'Workspace'; - title: string; - description: string; -} - -const nodeKindConfig = { - Trigger: { color: 'var(--agyn-yellow)', bgColor: 'var(--agyn-bg-yellow)' }, - Agent: { color: 'var(--agyn-blue)', bgColor: 'var(--agyn-bg-blue)' }, - Tool: { color: 'var(--agyn-cyan)', bgColor: 'var(--agyn-bg-cyan)' }, - MCP: { color: 'var(--agyn-cyan)', bgColor: 'var(--agyn-bg-cyan)' }, - Workspace: { color: 'var(--agyn-purple)', bgColor: 'var(--agyn-bg-purple)' }, -}; - -const mockNodeItems: DraggableNodeItem[] = [ - { - id: 'trigger-http', - kind: 'Trigger', - title: 'HTTP Trigger', - description: 'Start a workflow with an HTTP request', - }, - { - id: 'trigger-schedule', - kind: 'Trigger', - title: 'Schedule Trigger', - description: 'Run workflows on a schedule', - }, - { - id: 'agent-gpt4', - kind: 'Agent', - title: 'GPT-4 Agent', - description: 'AI agent powered by GPT-4', - }, - { - id: 'agent-claude', - kind: 'Agent', - title: 'Claude Agent', - description: 'AI agent powered by Claude', - }, - { - id: 'tool-search', - kind: 'Tool', - title: 'Web Search', - description: 'Search the web for information', - }, - { - id: 'tool-calculator', - kind: 'Tool', - title: 'Calculator', - description: 'Perform mathematical calculations', - }, - { - id: 'mcp-database', - kind: 'MCP', - title: 'Database MCP', - description: 'Connect to databases via MCP', - }, - { - id: 'mcp-files', - kind: 'MCP', - title: 'File System MCP', - description: 'Access file system operations', - }, - { - id: 'workspace-dev', - kind: 'Workspace', - title: 'Development Workspace', - description: 'Isolated environment for development', - }, -]; - -interface EmptySelectionSidebarProps { - nodeItems?: DraggableNodeItem[]; - defaultNodeItems?: DraggableNodeItem[]; - onNodeDragStart?: (nodeType: string) => void; - statusMessage?: string; -} - -export default function EmptySelectionSidebar({ - nodeItems = [], - defaultNodeItems = mockNodeItems, - onNodeDragStart, - statusMessage, -}: EmptySelectionSidebarProps) { - const runtimeNodeEnv = typeof process !== 'undefined' ? process.env?.NODE_ENV : undefined; - const isProductionRuntime = runtimeNodeEnv === 'production'; - const devFlag = import.meta.env.DEV; - const isDevEnvironment = !isProductionRuntime && (devFlag === true || String(devFlag) === 'true'); - const shouldShowMocks = isDevEnvironment && import.meta.env.VITE_UI_MOCK_SIDEBAR === 'true'; - const effectiveNodeItems = nodeItems.length > 0 ? nodeItems : shouldShowMocks ? defaultNodeItems : []; - const handleDragStart = (event: DragEvent, item: DraggableNodeItem) => { - event.dataTransfer.setData('application/reactflow', JSON.stringify(item)); - event.dataTransfer.effectAllowed = 'move'; - if (onNodeDragStart) { - onNodeDragStart(item.kind); - } - }; - - const hasItems = effectiveNodeItems.length > 0; - const emptyMessage = statusMessage && statusMessage.length > 0 ? statusMessage : 'No templates available.'; - - return ( -
- {/* Header */} -
-
-
- -
-
-

Build Your AI Team

-

- Add agents and tools to shape your own processes -

-
-
-
- - {/* Scrollable Content */} -
-
-
- Drag to Canvas -
-
- {hasItems ? ( - effectiveNodeItems.map((item) => { - const config = nodeKindConfig[item.kind]; - return ( -
handleDragStart(e, item)} - className="p-3 rounded-[8px] border border-[var(--agyn-border-subtle)] bg-white hover:border-[var(--agyn-border-medium)] hover:shadow-sm transition-all cursor-grab active:cursor-grabbing" - > -
-
-
- - {item.kind} - - - {item.title} - -
-

- {item.description} -

-
-
-
- ); - }) - ) : ( -
- {emptyMessage} -
- )} -
-
-
-
- ); -} diff --git a/packages/platform-ui/src/components/GraphCanvas.tsx b/packages/platform-ui/src/components/GraphCanvas.tsx deleted file mode 100644 index d4b5164c3..000000000 --- a/packages/platform-ui/src/components/GraphCanvas.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import React from 'react'; -import { - ReactFlow, - ReactFlowProvider, - Background, - Controls, - MiniMap, - SelectionMode, - useReactFlow, - type Edge, - type EdgeTypes, - type Node, - type NodeTypes, - type OnConnect, - type OnEdgesChange, - type OnNodesChange, - type OnNodesDelete, - type XYPosition, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; - -import NodeComponent, { type NodeKind } from './Node'; -import { SavingStatusControl, type SavingStatus } from './SavingStatusControl'; - -export type GraphNodeData = { - kind: NodeKind; - title?: string; - inputs?: { id: string; title: string }[]; - outputs?: { id: string; title: string }[]; - avatar?: string; - avatarSeed?: string; -}; - -const DRAGGABLE_NODE_KINDS: NodeKind[] = ['Trigger', 'Agent', 'Tool', 'MCP', 'Workspace']; - -function isDraggedNodeData(value: unknown): value is GraphCanvasDragData { - if (!value || typeof value !== 'object') { - return false; - } - const candidate = value as Record; - const { id, kind, title } = candidate; - return ( - typeof id === 'string' && - typeof title === 'string' && - typeof kind === 'string' && - DRAGGABLE_NODE_KINDS.includes(kind as NodeKind) - ); -} - -export interface GraphCanvasDragData { - id: string; - kind: NodeKind; - title: string; - description?: string; -} - -export interface GraphCanvasDropContext { - position: XYPosition; - data: GraphCanvasDragData; -} - -export type GraphCanvasDropHandler = ( - event: React.DragEvent, - context: GraphCanvasDropContext, -) => void; - -interface GraphCanvasProps { - nodes: Node[]; - edges: Edge[]; - onNodesChange: OnNodesChange; - onEdgesChange: OnEdgesChange; - onConnect: OnConnect; - nodeTypes?: NodeTypes; - edgeTypes?: EdgeTypes; - onDrop?: GraphCanvasDropHandler; - onDragOver?: (event: React.DragEvent) => void; - savingStatus?: SavingStatus; - savingErrorMessage?: string; - onNodesDelete?: OnNodesDelete>; -} - -export function GraphCanvas({ - nodes, - edges, - onNodesChange, - onEdgesChange, - onConnect, - nodeTypes, - edgeTypes, - onDrop, - onDragOver, - savingStatus, - savingErrorMessage, - onNodesDelete, -}: GraphCanvasProps) { - const defaultNodeTypes = React.useMemo( - () => ({ - graphNode: ({ data, selected }: { data: GraphNodeData; selected?: boolean }) => ( - - ), - }), - [], - ); - - const mergedNodeTypes = nodeTypes ? { ...defaultNodeTypes, ...nodeTypes } : defaultNodeTypes; - - return ( - - - - ); -} - -function ReactFlowInner({ - nodes, - edges, - onNodesChange, - onEdgesChange, - onConnect, - nodeTypes, - edgeTypes, - onDrop, - onDragOver, - savingStatus, - savingErrorMessage, - onNodesDelete, -}: Omit & { nodeTypes: NodeTypes }) { - const reactFlowInstance = useReactFlow(); - - const handleDragOver = React.useCallback((event: React.DragEvent) => { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - if (onDragOver) { - onDragOver(event); - } - }, [onDragOver]); - - const handleDrop = React.useCallback((event: React.DragEvent) => { - event.preventDefault(); - - const data = event.dataTransfer.getData('application/reactflow'); - if (!data) { - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - return; - } - - if (!isDraggedNodeData(parsed)) { - return; - } - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - - onDrop?.(event, { position, data: parsed }); - }, [onDrop, reactFlowInstance]); - - return ( -
- - - - - - {savingStatus && ( -
- -
- )} -
- ); -} 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/__tests__/EmptySelectionSidebar.prod-gating.test.tsx b/packages/platform-ui/src/components/__tests__/EmptySelectionSidebar.prod-gating.test.tsx deleted file mode 100644 index 84a253421..000000000 --- a/packages/platform-ui/src/components/__tests__/EmptySelectionSidebar.prod-gating.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; - -import EmptySelectionSidebar from '../EmptySelectionSidebar'; - -describe('EmptySelectionSidebar production gating', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('hides mock node items when not in dev mode, even if mock flag enabled', () => { - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; - vi.stubEnv('DEV', 'false'); - vi.stubEnv('VITE_UI_MOCK_SIDEBAR', 'true'); - - try { - render(); - - expect(screen.getByText('No templates available.')).toBeInTheDocument(); - expect(screen.queryByText('HTTP Trigger')).not.toBeInTheDocument(); - } finally { - process.env.NODE_ENV = originalNodeEnv; - } - }); -}); diff --git a/packages/platform-ui/src/components/__tests__/EmptySelectionSidebar.test.tsx b/packages/platform-ui/src/components/__tests__/EmptySelectionSidebar.test.tsx deleted file mode 100644 index d08453ed9..000000000 --- a/packages/platform-ui/src/components/__tests__/EmptySelectionSidebar.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; - -import EmptySelectionSidebar, { type DraggableNodeItem } from '../EmptySelectionSidebar'; - -describe('EmptySelectionSidebar', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('renders provided node items', () => { - const items: DraggableNodeItem[] = [ - { - id: 'custom-node', - kind: 'Agent', - title: 'Custom Node', - description: 'Configured from test', - }, - ]; - - render(); - - expect(screen.getByText('Custom Node')).toBeInTheDocument(); - expect(screen.queryByText('No templates available.')).not.toBeInTheDocument(); - }); - - it('does not render mock items when the sidebar mock flag is disabled', () => { - vi.stubEnv('VITE_UI_MOCK_SIDEBAR', 'false'); - - render(); - - expect(screen.getByText('No templates available.')).toBeInTheDocument(); - expect(screen.queryByText('HTTP Trigger')).not.toBeInTheDocument(); - }); - - it('renders mock items only when the dev flag is enabled', () => { - vi.stubEnv('DEV', 'true'); - vi.stubEnv('VITE_UI_MOCK_SIDEBAR', 'true'); - - render(); - - expect(screen.getByText('HTTP Trigger')).toBeInTheDocument(); - expect(screen.queryByText('No templates available.')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/platform-ui/src/components/__tests__/GraphCanvas.test.tsx b/packages/platform-ui/src/components/__tests__/GraphCanvas.test.tsx deleted file mode 100644 index 14958c75d..000000000 --- a/packages/platform-ui/src/components/__tests__/GraphCanvas.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { type ReactNode } from 'react'; -import { render } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { GraphCanvas } from '../GraphCanvas'; - -const reactFlowPropsSpy = vi.fn(); - -vi.mock('@xyflow/react', () => { - return { - ReactFlowProvider: ({ children }: { children?: ReactNode }) => ( -
{children}
- ), - ReactFlow: (props: any) => { - reactFlowPropsSpy(props); - return
{props.children}
; - }, - Background: () =>
, - Controls: () =>
, - MiniMap: () =>
, - SelectionMode: { Partial: 'partial' }, - useReactFlow: () => ({ - screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ x, y }), - }), - }; -}); - -describe('GraphCanvas', () => { - beforeEach(() => { - reactFlowPropsSpy.mockClear(); - }); - - it('forwards onNodesDelete and sets tabIndex for keyboard interactions', () => { - const nodesDeleteHandler = vi.fn(); - - render( - , - ); - - expect(reactFlowPropsSpy).toHaveBeenCalled(); - const props = reactFlowPropsSpy.mock.calls.at(-1)?.[0] ?? {}; - expect(props.onNodesDelete).toBe(nodesDeleteHandler); - expect(props.tabIndex).toBe(0); - }); -}); diff --git a/packages/platform-ui/src/components/agents/GraphLayout.tsx b/packages/platform-ui/src/components/agents/GraphLayout.tsx deleted file mode 100644 index c904f4c57..000000000 --- a/packages/platform-ui/src/components/agents/GraphLayout.tsx +++ /dev/null @@ -1,898 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - addEdge, - applyEdgeChanges, - applyNodeChanges, - type Edge, - type EdgeTypes, - type Node, - type OnNodesDelete, -} from '@xyflow/react'; - -import { GraphCanvas, type GraphCanvasDropHandler, type GraphNodeData } from '../GraphCanvas'; -import { GradientEdge } from './edges/GradientEdge'; -import EmptySelectionSidebar from '../EmptySelectionSidebar'; -import NodePropertiesSidebar, { type NodeConfig as SidebarNodeConfig } from '../NodePropertiesSidebar'; -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 { 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'; - -type FlowNode = Node; - -type FlowEdgeData = { - sourceColor: string; - targetColor: string; - sourceKind?: GraphNodeConfig['kind']; - targetKind?: GraphNodeConfig['kind']; -}; - -type FlowEdge = Edge; - -const nodeKindToColor: Record = { - Trigger: 'var(--agyn-yellow)', - Agent: 'var(--agyn-blue)', - Tool: 'var(--agyn-cyan)', - MCP: 'var(--agyn-cyan)', - Workspace: 'var(--agyn-purple)', -}; - -const defaultSourceColor = 'var(--agyn-blue)'; -const defaultTargetColor = 'var(--agyn-purple)'; -const ACTION_GUARD_INTERVAL_MS = 600; -const SECRET_SUGGESTION_TTL_MS = 5 * 60 * 1000; -const VARIABLE_SUGGESTION_TTL_MS = 5 * 60 * 1000; - -export interface GraphLayoutServices { - searchNixPackages: (query: string) => Promise>; - listNixPackageVersions: (name: string) => Promise>; - resolveNixSelection: (name: string, version: string) => Promise<{ version: string; commit: string; attr: string }>; - listVariableKeys: () => Promise; -} - -export interface GraphLayoutProps { - services: GraphLayoutServices; -} - -function resolveAgentGraphTitle(node: GraphNodeConfig): string { - const config = (node.config ?? {}) as Record; - const rawConfigTitle = typeof config.title === 'string' ? (config.title as string) : ''; - const trimmedConfigTitle = rawConfigTitle.trim(); - if (trimmedConfigTitle.length > 0) { - return trimmedConfigTitle; - } - - const fallback = resolveAgentDisplayTitle({ - title: undefined, - name: typeof config.name === 'string' ? (config.name as string) : undefined, - role: typeof config.role === 'string' ? (config.role as string) : undefined, - }); - - if (fallback !== 'Agent') { - return fallback; - } - - const storedTitleRaw = typeof node.title === 'string' ? node.title : ''; - const storedTitle = storedTitleRaw.trim(); - return storedTitle.length > 0 ? storedTitle : fallback; -} - -function resolveDisplayTitle(node: GraphNodeConfig): string { - if (node.kind === 'Agent') { - return resolveAgentGraphTitle(node); - } - const rawTitle = typeof node.title === 'string' ? node.title : ''; - const trimmed = rawTitle.trim(); - return trimmed.length > 0 ? trimmed : rawTitle; -} - -function toFlowNode(node: GraphNodeConfig): FlowNode { - return { - id: node.id, - type: 'graphNode', - position: { x: node.x, y: node.y }, - data: { - kind: node.kind, - title: resolveDisplayTitle(node), - inputs: node.ports.inputs, - outputs: node.ports.outputs, - avatarSeed: node.avatarSeed, - }, - selected: false, - } satisfies FlowNode; -} - -function encodeHandle(handle?: string | null): string { - if (typeof handle === 'string' && handle.length > 0 && handle !== '$') { - return handle; - } - return '$'; -} - -function decodeHandle(handle?: string | null): string | undefined { - if (!handle || handle === '$') { - return undefined; - } - return handle; -} - -function buildEdgeId( - source: string, - sourceHandle: string | null | undefined, - target: string, - targetHandle: string | null | undefined, -): string { - return `${source}-${encodeHandle(sourceHandle)}__${target}-${encodeHandle(targetHandle)}`; -} - -function generateGraphNodeId(): string { - return getUuid(); -} - -function makeEdgeData( - sourceNode?: GraphNodeConfig, - targetNode?: GraphNodeConfig, -): FlowEdgeData { - const sourceKind = sourceNode?.kind; - const targetKind = targetNode?.kind; - return { - sourceColor: sourceKind ? nodeKindToColor[sourceKind] ?? defaultSourceColor : defaultSourceColor, - targetColor: targetKind ? nodeKindToColor[targetKind] ?? defaultTargetColor : defaultTargetColor, - sourceKind, - targetKind, - } satisfies FlowEdgeData; -} - -function toFlowEdge(edge: GraphPersistedEdge, data: FlowEdgeData): FlowEdge { - const sourceHandle = decodeHandle(edge.sourceHandle); - const targetHandle = decodeHandle(edge.targetHandle); - return { - id: buildEdgeId(edge.source, sourceHandle, edge.target, targetHandle), - type: 'gradient', - source: edge.source, - target: edge.target, - sourceHandle, - targetHandle, - data, - } satisfies FlowEdge; -} - -function fromFlowEdge(edge: FlowEdge): GraphPersistedEdge { - return { - id: buildEdgeId(edge.source, edge.sourceHandle, edge.target, edge.targetHandle), - source: edge.source, - target: edge.target, - sourceHandle: encodeHandle(edge.sourceHandle), - targetHandle: encodeHandle(edge.targetHandle), - } satisfies GraphPersistedEdge; -} - -function mapProvisionState(status?: ApiNodeStatus): GraphNodeStatus | undefined { - const state = status?.provisionStatus?.state; - switch (state) { - case 'ready': - return 'ready'; - case 'provisioning': - return 'provisioning'; - case 'deprovisioning': - return 'deprovisioning'; - case 'provisioning_error': - return 'provisioning_error'; - case 'deprovisioning_error': - return 'deprovisioning_error'; - case 'error': - return 'provisioning_error'; - case 'not_ready': - default: - return state ? 'not_ready' : undefined; - } -} - -export function GraphLayout({ services }: GraphLayoutProps) { - const { - nodes, - edges, - loading, - savingState, - savingErrorMessage, - updateNode, - applyNodeStatus, - applyNodeState, - setEdges, - removeNodes, - addNode, - scheduleSave, - } = useGraphData(); - - const secretKeysRef = useRef(null); - const secretKeysPromiseRef = useRef | null>(null); - const secretKeysFetchedAtRef = useRef(0); - const [secretKeys, setSecretKeys] = useState([]); - const variableKeysRef = useRef(null); - const variableKeysPromiseRef = useRef | null>(null); - const variableKeysFetchedAtRef = useRef(0); - const [variableKeys, setVariableKeys] = useState([]); - const updateNodeRef = useRef(updateNode); - const setEdgesRef = useRef(setEdges); - const nodesRef = useRef(nodes); - const processedRemovedNodeIdsRef = useRef>(new Set()); - - const filterUnprocessedRemovedNodeIds = useCallback((ids: string[]): string[] => { - if (ids.length === 0) { - return ids; - } - const processed = processedRemovedNodeIdsRef.current; - const next: string[] = []; - for (const id of ids) { - if (!processed.has(id)) { - processed.add(id); - next.push(id); - } - } - if (next.length > 0) { - setTimeout(() => { - const registry = processedRemovedNodeIdsRef.current; - for (const id of next) { - registry.delete(id); - } - }, 0); - } - return next; - }, []); - - const ensureSecretKeys = useCallback(async (): Promise => { - const cached = secretKeysRef.current; - const now = Date.now(); - if (cached && now - secretKeysFetchedAtRef.current < SECRET_SUGGESTION_TTL_MS) { - setSecretKeys((current) => (current === cached ? current : cached)); - return cached; - } - - if (!secretKeysPromiseRef.current) { - secretKeysPromiseRef.current = listAllSecretPaths() - .then((items) => { - const sanitized = Array.isArray(items) - ? items.filter((item): item is string => typeof item === 'string' && item.length > 0) - : []; - secretKeysRef.current = sanitized; - secretKeysFetchedAtRef.current = Date.now(); - setSecretKeys(sanitized); - return sanitized; - }) - .catch(() => { - secretKeysRef.current = []; - secretKeysFetchedAtRef.current = Date.now(); - setSecretKeys([]); - return []; - }) - .finally(() => { - secretKeysPromiseRef.current = null; - }); - } - - try { - return await secretKeysPromiseRef.current; - } catch { - return []; - } - }, []); - - const handleNixPackageSearch = useCallback( - async (query: string): Promise> => { - const trimmed = query.trim(); - if (trimmed.length < 2) return []; - try { - const result = await services.searchNixPackages(trimmed); - return result - .filter((item) => item && typeof item.name === 'string') - .map((item) => ({ value: item.name, label: item.name })); - } catch { - return []; - } - }, - [services], - ); - - const handleFetchNixPackageVersions = useCallback( - async (name: string): Promise => { - if (!name) return []; - try { - const result = await services.listNixPackageVersions(name); - return result - .map((item) => item?.version) - .filter((version): version is string => typeof version === 'string' && version.length > 0); - } catch { - return []; - } - }, - [services], - ); - - const handleResolveNixPackageSelection = useCallback( - async (name: string, version: string) => { - const resolved = await services.resolveNixSelection(name, version); - if (!resolved || typeof resolved.version !== 'string') { - throw new Error('nix-resolve-invalid'); - } - return { - version: resolved.version, - commitHash: resolved.commit, - attributePath: resolved.attr, - }; - }, - [services], - ); - - const ensureVariableKeys = useCallback(async (): Promise => { - const cached = variableKeysRef.current; - const now = Date.now(); - if (cached && now - variableKeysFetchedAtRef.current < VARIABLE_SUGGESTION_TTL_MS) { - setVariableKeys((current) => (current === cached ? current : cached)); - return cached; - } - - if (!variableKeysPromiseRef.current) { - variableKeysPromiseRef.current = services - .listVariableKeys() - .then((items) => { - const sanitized = Array.isArray(items) - ? items.filter((item): item is string => typeof item === 'string' && item.length > 0) - : []; - variableKeysRef.current = sanitized; - variableKeysFetchedAtRef.current = Date.now(); - setVariableKeys(sanitized); - return sanitized; - }) - .catch(() => { - variableKeysRef.current = []; - variableKeysFetchedAtRef.current = Date.now(); - setVariableKeys([]); - return []; - }) - .finally(() => { - variableKeysPromiseRef.current = null; - }); - } - - try { - return await variableKeysPromiseRef.current; - } catch { - return []; - } - }, [services]); - - - useEffect(() => { - updateNodeRef.current = updateNode; - }, [updateNode]); - - useEffect(() => { - setEdgesRef.current = setEdges; - }, [setEdges]); - - useEffect(() => { - nodesRef.current = nodes; - }, [nodes]); - - const nodeIds = useMemo(() => nodes.map((node) => node.id), [nodes]); - - useGraphSocket({ - nodeIds, - onStatus: (event) => { - const { nodeId, updatedAt: _ignored, ...status } = event; - applyNodeStatus(nodeId, status); - }, - onState: (event) => { - applyNodeState(event.nodeId, event.state ?? {}); - }, - }); - - const [flowNodes, setFlowNodes] = useState([]); - const [flowEdges, setFlowEdges] = useState([]); - const [selectedNodeId, setSelectedNodeId] = useState(null); - const selectedNodeIdRef = useRef(null); - const flowNodesRef = useRef([]); - const flowEdgesRef = useRef([]); - 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(() => { - if (!Array.isArray(templatesQuery.data) || templatesQuery.data.length === 0) { - return null; - } - const map = new Map(); - for (const tpl of templatesQuery.data) { - if (!tpl || typeof tpl !== 'object') { - continue; - } - const name = typeof tpl.name === 'string' ? tpl.name.trim() : ''; - if (!name) { - continue; - } - map.set(name, tpl); - } - return map.size > 0 ? map : null; - }, [templatesQuery.data]); - const canAcceptDrop = !templatesQuery.isLoading && !!templatesByName && templatesByName.size > 0; - const sidebarStatusMessage = useMemo(() => { - if (templatesQuery.isLoading) { - return 'Loading templates...'; - } - if (templatesQuery.isError && sidebarNodeItems.length === 0) { - return 'Failed to load templates.'; - } - if (!templatesQuery.isLoading && sidebarNodeItems.length === 0) { - return 'No templates available.'; - } - return undefined; - }, [sidebarNodeItems.length, templatesQuery.isError, templatesQuery.isLoading]); - - useEffect(() => { - selectedNodeIdRef.current = selectedNodeId; - }, [selectedNodeId]); - - useEffect(() => { - const currentSelected = selectedNodeIdRef.current; - if (!currentSelected) { - return; - } - const exists = nodes.some((node) => node.id === currentSelected); - if (!exists) { - setSelectedNodeId(null); - } - }, [nodes]); - - useEffect(() => { - setFlowNodes((prev) => { - const prevById = new Map(prev.map((item) => [item.id, item] as const)); - let changed = prev.length !== nodes.length; - const next: FlowNode[] = nodes.map((node, index) => { - const existing = prevById.get(node.id); - const nextData = { - kind: node.kind, - title: resolveDisplayTitle(node), - inputs: node.ports.inputs, - outputs: node.ports.outputs, - avatarSeed: node.avatarSeed, - } satisfies FlowNode['data']; - if (!existing) { - changed = true; - return toFlowNode(node); - } - const basePosition = existing.position ?? { x: node.x, y: node.y }; - const dataMatches = - existing.data.kind === nextData.kind && - existing.data.title === nextData.title && - existing.data.avatarSeed === nextData.avatarSeed && - existing.data.inputs === nextData.inputs && - existing.data.outputs === nextData.outputs; - const positionMatches = - existing.position?.x === basePosition.x && existing.position?.y === basePosition.y; - let nextNode = existing; - if (!dataMatches) { - nextNode = { - ...existing, - data: nextData, - } satisfies FlowNode; - } - if (!positionMatches) { - nextNode = { - ...nextNode, - position: basePosition, - } satisfies FlowNode; - } - if (nextNode !== existing) { - changed = true; - } - if (!changed && prev[index]?.id !== node.id) { - changed = true; - } - return nextNode; - }); - if (!changed) { - return prev; - } - return next; - }); - }, [nodes]); - - useEffect(() => { - setFlowNodes((prev) => { - let changed = false; - const next = prev.map((node) => { - const shouldSelect = node.id === selectedNodeId; - if (node.selected === shouldSelect) { - return node; - } - changed = true; - return { ...node, selected: shouldSelect }; - }); - if (!changed) { - return prev; - } - return next; - }); - }, [selectedNodeId]); - - useEffect(() => { - flowNodesRef.current = flowNodes; - }, [flowNodes]); - - useEffect(() => { - setFlowEdges((prev) => { - const nodeMap = new Map(nodes.map((node) => [node.id, node] as const)); - const nextEdges = edges.map((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); - return toFlowEdge(edge, makeEdgeData(sourceNode, targetNode)); - }); - const prevLength = prev.length; - if (prevLength === nextEdges.length) { - let changed = false; - for (let index = 0; index < prevLength; index += 1) { - const prevEdge = prev[index]; - const nextEdge = nextEdges[index]; - if (prevEdge.id !== nextEdge.id) { - changed = true; - break; - } - const prevData = prevEdge.data; - const nextData = nextEdge.data; - if (prevData === nextData) { - continue; - } - if (!prevData || !nextData) { - changed = true; - break; - } - if ( - prevData.sourceColor !== nextData.sourceColor || - prevData.targetColor !== nextData.targetColor || - prevData.sourceKind !== nextData.sourceKind || - prevData.targetKind !== nextData.targetKind - ) { - changed = true; - break; - } - } - if (!changed) { - flowEdgesRef.current = prev; - return prev; - } - } - flowEdgesRef.current = nextEdges; - return nextEdges; - }); - }, [edges, nodes]); - - const selectedNode = useMemo( - () => (selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) ?? null : null), - [nodes, selectedNodeId], - ); - - const statusQuery = useNodeStatus(selectedNodeId ?? ''); - const actionNodeId = selectedNode?.id ?? null; - const nodeAction = useNodeAction(actionNodeId); - const { mutateAsync: runNodeAction, isPending: isActionPending } = nodeAction; - const { refetch: refetchStatus } = statusQuery; - const mcpNodeId = selectedNode?.kind === 'MCP' ? selectedNode.id : null; - const { - tools: mcpTools, - enabledTools: mcpEnabledTools, - setEnabledTools: setMcpEnabledTools, - isLoading: mcpToolsLoading, - } = useMcpNodeState(mcpNodeId); - - const handleToggleMcpTool = useCallback( - (toolName: string, enabled: boolean) => { - if (!mcpNodeId) return; - const current = mcpEnabledTools ?? []; - const next = new Set(current); - if (enabled) { - next.add(toolName); - } else { - next.delete(toolName); - } - setMcpEnabledTools(Array.from(next)); - }, - [mcpEnabledTools, mcpNodeId, setMcpEnabledTools], - ); - - const handleNodesChange = useCallback((changes: Parameters[0]) => { - let nextSelectedId = selectedNodeIdRef.current; - const removedIds: string[] = []; - for (const change of changes) { - if (change.type === 'select' && 'id' in change) { - if (change.selected) { - nextSelectedId = change.id; - } else if (nextSelectedId === change.id) { - nextSelectedId = null; - } - } - - if (change.type === 'remove' && 'id' in change) { - removedIds.push(change.id); - if (nextSelectedId === change.id) { - nextSelectedId = null; - } - } - } - - setSelectedNodeId(nextSelectedId ?? null); - - const previousNodes = flowNodesRef.current; - const applied = applyNodeChanges(changes, previousNodes) as FlowNode[]; - if (applied !== previousNodes) { - flowNodesRef.current = applied; - setFlowNodes(applied); - } - - if (removedIds.length > 0) { - const uniqueIds = filterUnprocessedRemovedNodeIds(removedIds); - if (uniqueIds.length > 0) { - removeNodes(uniqueIds); - } - } - - for (const change of changes) { - if (change.type === 'position' && (change.dragging === false || change.dragging === undefined) && 'id' in change) { - const moved = applied.find((node) => node.id === change.id); - if (!moved) continue; - const { x, y } = moved.position ?? { x: 0, y: 0 }; - updateNodeRef.current(change.id, { x, y }); - } - } - }, [filterUnprocessedRemovedNodeIds, removeNodes]); - - const handleNodesDelete = useCallback>((deletedNodes) => { - if (!deletedNodes || deletedNodes.length === 0) { - return; - } - const ids = deletedNodes - .map((node) => node.id) - .filter((id): id is string => typeof id === 'string' && id.length > 0); - const uniqueIds = filterUnprocessedRemovedNodeIds(ids); - if (uniqueIds.length === 0) { - return; - } - removeNodes(uniqueIds); - }, [filterUnprocessedRemovedNodeIds, removeNodes]); - - const handleEdgesChange = useCallback((changes: Parameters[0]) => { - const current = flowEdgesRef.current; - const applied = applyEdgeChanges(changes, current) as FlowEdge[]; - if (applied !== current) { - flowEdgesRef.current = applied; - setFlowEdges(applied); - } - const shouldPersist = changes.some((change) => - change.type === 'remove' || change.type === 'add' || change.type === 'replace', - ); - if (!shouldPersist) { - return; - } - const nextPersisted = applied.map(fromFlowEdge); - setEdgesRef.current(nextPersisted); - }, []); - - const handleConnect = useCallback((connection: Parameters[0]) => { - if (!connection?.source || !connection?.target) { - return; - } - const current = flowEdgesRef.current; - const edgeId = buildEdgeId( - connection.source, - connection.sourceHandle ?? null, - connection.target, - connection.targetHandle ?? null, - ); - if (current.some((edge) => edge.id === edgeId)) { - return; - } - const nodeList = nodesRef.current; - const sourceNode = nodeList.find((node) => node.id === connection.source); - const targetNode = nodeList.find((node) => node.id === connection.target); - const edgeData = makeEdgeData(sourceNode, targetNode); - const nextEdges = addEdge( - { ...connection, id: edgeId, type: 'gradient', data: edgeData }, - current, - ) as FlowEdge[]; - flowEdgesRef.current = nextEdges; - setFlowEdges(nextEdges); - const persisted = nextEdges.map(fromFlowEdge); - setEdgesRef.current(persisted); - }, []); - - const sidebarStatus: GraphNodeStatus = useMemo(() => { - const fromApi = mapProvisionState(statusQuery.data); - if (fromApi) { - return fromApi; - } - if (selectedNode?.status) { - return selectedNode.status; - } - return 'not_ready'; - }, [selectedNode?.status, statusQuery.data]); - - const canProvision = - sidebarStatus === 'not_ready' || - sidebarStatus === 'provisioning_error' || - sidebarStatus === 'deprovisioning_error'; - - const canDeprovision = sidebarStatus === 'ready' || sidebarStatus === 'provisioning'; - - const sidebarConfig = useMemo(() => { - if (!selectedNode) { - return null; - } - const baseConfig = (selectedNode.config ?? {}) as Record; - const configTitle = - typeof baseConfig.title === 'string' ? (baseConfig.title as string) : ''; - - return { - ...baseConfig, - kind: selectedNode.kind, - template: selectedNode.template, - title: configTitle, - } as SidebarNodeConfig; - }, [selectedNode]); - - const sidebarDisplayTitle = typeof selectedNode?.title === 'string' ? selectedNode.title : ''; - - const sidebarState = useMemo(() => ({ status: sidebarStatus }), [sidebarStatus]); - - const handleConfigChange = useCallback( - (nextConfig: Partial) => { - const nodeId = selectedNodeIdRef.current; - if (!nodeId) return; - const node = nodesRef.current.find((item) => item.id === nodeId); - if (!node) return; - - const baseConfig = { ...(node.config ?? {}) } as Record; - delete baseConfig.kind; - delete baseConfig.template; - - const patch = { ...(nextConfig ?? {}) } as Record; - const rawTitleUpdate = typeof patch.title === 'string' ? (patch.title as string) : undefined; - delete patch.kind; - delete patch.template; - - const updatedConfig: Record = { - ...baseConfig, - ...patch, - }; - - const updates: { config: Record; title?: string } = { - config: updatedConfig, - }; - - if (rawTitleUpdate !== undefined) { - const currentTitle = typeof node.title === 'string' ? node.title : ''; - if (rawTitleUpdate !== currentTitle) { - updates.title = rawTitleUpdate; - } - } - - updateNodeRef.current(nodeId, updates); - }, - [], - ); - - const handleNodeAction = useCallback( - (action: 'provision' | 'deprovision') => { - if (!actionNodeId) return; - if (isActionPending) return; - const now = Date.now(); - if (now - lastActionAtRef.current < ACTION_GUARD_INTERVAL_MS) { - return; - } - lastActionAtRef.current = now; - void runNodeAction(action).finally(() => { - if (actionNodeId) { - void refetchStatus(); - } - }); - }, - [actionNodeId, isActionPending, refetchStatus, runNodeAction], - ); - - const handleProvision = useCallback(() => { - handleNodeAction('provision'); - }, [handleNodeAction]); - - const handleDeprovision = useCallback(() => { - handleNodeAction('deprovision'); - }, [handleNodeAction]); - - const handleDrop = useCallback((_event, { data, position }) => { - if (!templatesByName) { - return; - } - const template = templatesByName.get(data.id); - if (!template) { - return; - } - const x = Number.isFinite(position?.x) ? position.x : 0; - const y = Number.isFinite(position?.y) ? position.y : 0; - const nodeId = generateGraphNodeId(); - const rawTitle = typeof data.title === 'string' ? data.title.trim() : ''; - const config = rawTitle.length > 0 ? { title: rawTitle } : undefined; - const { node, metadata } = buildGraphNodeFromTemplate(template, { - id: nodeId, - position: { x, y }, - title: rawTitle || undefined, - config, - }); - - addNode(node, metadata); - scheduleSave(); - }, [addNode, scheduleSave, templatesByName]); - - if (loading && nodes.length === 0) { - return ( -
- Loading graph... -
- ); - } - - return ( -
-
- -
- {selectedNode && sidebarConfig ? ( - - ) : ( - - )} -
- ); -} diff --git a/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx b/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx index e22db7756..40937ab72 100644 --- a/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx +++ b/packages/platform-ui/src/components/entities/EntityUpsertForm.tsx @@ -23,10 +23,16 @@ import type { TemplateOption, } 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 { useMcpTools } from '@/lib/graph/hooks'; +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: @@ -593,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) => { @@ -736,8 +674,8 @@ export function EntityUpsertForm({ ensureSecretKeys, ensureVariableKeys, tools: mcpTools, - enabledTools: mcpEnabledTools, - onToggleTool: handleToggleMcpTool, + toolsUpdatedAt: mcpToolsUpdatedAt, + onDiscoverTools: handleDiscoverMcpTools, toolsLoading: mcpToolsLoading, nodeId: nodeIdForView, graphNodes: safeGraphNodes, @@ -819,8 +757,8 @@ export function EntityUpsertForm({ safeGraphNodes, safeGraphEdges, mcpTools, - mcpEnabledTools, - handleToggleMcpTool, + mcpToolsUpdatedAt, + handleDiscoverMcpTools, mcpToolsLoading, ]); @@ -866,15 +804,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/graph/NodeActionButtons.tsx b/packages/platform-ui/src/components/graph/NodeActionButtons.tsx deleted file mode 100644 index bcf83d30d..000000000 --- a/packages/platform-ui/src/components/graph/NodeActionButtons.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button } from '@/components/Button'; - -export interface NodeActionButtonsProps { - provisionable: boolean; - pausable: boolean; - canStart: boolean; - canStop: boolean; - onStart: () => void; - onStop: () => void; -} - -export function NodeActionButtons({ provisionable, pausable, canStart, canStop, onStart, onStop }: NodeActionButtonsProps) { - if (!provisionable && !pausable) return null; - return ( -
-
Actions
-
- {provisionable && ( - <> - - - - )} - {/* Pause/Resume removed; buttons gated off */} -
-
- ); -} - -export default NodeActionButtons; diff --git a/packages/platform-ui/src/components/graph/NodeDetailsPanel.tsx b/packages/platform-ui/src/components/graph/NodeDetailsPanel.tsx deleted file mode 100644 index c8a837b2f..000000000 --- a/packages/platform-ui/src/components/graph/NodeDetailsPanel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -// React import not needed with react-jsx runtime -import { Badge } from '@/components/Badge'; -import { Button } from '@/components/Button'; -import { useNodeStatus, useNodeAction } from '../../lib/graph/hooks'; - -interface Props { nodeId: string; templateName: string; } - -export default function NodeDetailsPanel({ nodeId, templateName }: Props) { - const { data: status } = useNodeStatus(nodeId); - const action = useNodeAction(nodeId); - - // Default to not_ready (tests expect this baseline) until first fetch resolves - const provisionState = status?.provisionStatus?.state || 'not_ready'; - const canStart = ['not_ready', 'error', 'provisioning_error', 'deprovisioning_error'].includes(provisionState); - const canStop = ['ready', 'provisioning'].includes(provisionState); - - return ( -
-

Node {nodeId}

-
- Template: {templateName} - - {provisionState} - - {/* Pause/Resume removed per server alignment; badge removed */} -
-
- - {/* Pause/Resume removed; only Start/Stop */} - -
-
- ); -} diff --git a/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx b/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx deleted file mode 100644 index ce9be1add..000000000 --- a/packages/platform-ui/src/components/graph/NodeStatusBadges.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Badge } from '@/components/Badge'; -import type { ProvisionState } from '@/api/types/graph'; -import { badgeVariantForColor, badgeVariantForState, isFailedProvisionState } from '../entities/provisionStatusDisplay'; - -export function NodeStatusBadges({ state, isPaused, detail }: { state: ProvisionState | string; isPaused: boolean; detail: unknown }) { - return ( -
- {state} - {isPaused && paused} - {isFailedProvisionState(state) && detail ? ( - - details - - ) : null} -
- ); -} - -export default NodeStatusBadges; diff --git a/packages/platform-ui/src/components/graph/NodeTracingSidebar.tsx b/packages/platform-ui/src/components/graph/NodeTracingSidebar.tsx deleted file mode 100644 index c621dba6b..000000000 --- a/packages/platform-ui/src/components/graph/NodeTracingSidebar.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { Node } from 'reactflow'; -import { useTemplatesCache } from '@/lib/graph/templates.provider'; -import { useNodeReminders } from '@/lib/graph/hooks'; -import { graph as api } from '@/api/modules/graph'; -import { notifyError, notifySuccess } from '@/lib/notify'; - -type BuilderPanelNodeData = { - template: string; - name?: string; - config?: Record; - dynamicConfig?: Record; -}; - -function detectKind(templateKind: string | undefined, templateName: string): 'agent' | 'tool' | 'other' { - if (templateKind === 'agent') return 'agent'; - if (templateKind === 'tool' || templateKind === 'mcp') return 'tool'; - if (/agent/i.test(templateName)) return 'agent'; - if (/tool/i.test(templateName)) return 'tool'; - return 'other'; -} - -export function NodeTracingSidebar({ node }: { node: Node }) { - return ; -} - -function NodeTracingSidebarBody({ node }: { node: Node }) { - const { getTemplate } = useTemplatesCache(); - const template = getTemplate(node.data.template); - const kind = detectKind(template?.kind, node.data.template); - - const reminders = useNodeReminders(node.id, node.data.template === 'remindMeTool'); - const [runs, setRuns] = useState>([]); - const [terminating, setTerminating] = useState>({}); - - useEffect(() => { - let cancelled = false; - let timer: ReturnType | undefined; - - const tick = async () => { - try { - const res = await api.listNodeRuns(node.id, 'all'); - if (cancelled) return; - const items = (res.items || []).map((r) => ({ runId: r.runId, threadId: r.threadId, status: r.status, updatedAt: r.updatedAt })); - setRuns(items); - } catch { - /* no-op */ - } finally { - if (!cancelled) timer = setTimeout(tick, 3000); - } - }; - - if (kind === 'agent') tick(); - - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [node, kind]); - - async function onTerminate(runId: string) { - const ok = typeof window !== 'undefined' ? window.confirm('Terminate this run?') : true; - if (!ok) return; - try { - setTerminating((prev) => ({ ...prev, [runId]: true })); - await api.terminateRun(runId); - notifySuccess('Termination signaled'); - setRuns((prev) => prev.map((r) => (r.runId === runId ? { ...r, status: 'terminating' } : r))); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - notifyError(`Failed to terminate: ${msg}`); - } finally { - setTerminating((prev) => ({ ...prev, [runId]: false })); - } - } - - const title = kind === 'agent' - ? 'Agent Activity' - : kind === 'tool' - ? 'Tool Activity' - : 'Node Activity'; - - return ( -
- {node.data.template === 'remindMeTool' && ( -
-
Active Reminders
- {reminders.isLoading ? ( -
Loading…
- ) : reminders.error ? ( -
- {(() => { - const err = reminders.error as unknown; - const msg = err instanceof Error ? err.message : String(err); - return `Failed to load reminders: ${msg}`; - })()} -
- ) : (reminders.data?.items?.length || 0) === 0 ? ( -
None
- ) : ( -
    - {reminders.data!.items.map((r) => ( -
  • -
    -
    {r.note}
    -
    thread: {r.threadId}
    -
    -
    - {new Date(r.at).toLocaleTimeString()} -
    -
  • - ))} -
- )} -
- )} - - {kind === 'agent' && ( -
-
Active Runs
- {runs.length === 0 ? ( -
None
- ) : ( -
    - {runs.map((r) => ( -
  • -
    -
    {r.threadId}
    -
    {r.runId}
    -
    -
    - {r.status} - {r.status === 'running' && ( - - )} -
    -
  • - ))} -
- )} -
- )} - -
{title}
-
- Tracing has been removed from the platform. Span history and realtime tracing views are no longer available. -
-
- ); -} diff --git a/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx b/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx deleted file mode 100644 index 49b833c1a..000000000 --- a/packages/platform-ui/src/components/graph/__tests__/NodeDetailsPanel.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -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 mockMutate = vi.fn(); - -vi.mock('../../../lib/graph/hooks', () => ({ - useNodeStatus: () => ({ data: mockStatus }), - useNodeAction: () => ({ mutate: (...args: any[]) => mockMutate(...args) }), -})); - -describe('NodeDetailsPanel', () => { - beforeEach(() => { - mockStatus = { isPaused: false, provisionStatus: { state: 'not_ready' } }; - mockMutate = vi.fn(); - }); - - const renderPanel = (props: any = {}) => { - const qc = new QueryClient(); - return render( - - - , - ); - }; - - it('renders title and chips', () => { - renderPanel(); - expect(screen.getByText(/Node n1/)).toBeInTheDocument(); - expect(screen.getByText(/Template:/)).toBeInTheDocument(); - expect(screen.getByText('not_ready')).toBeInTheDocument(); - }); - - it('enables Provision on not_ready and calls provision', () => { - mockStatus = { isPaused: false, provisionStatus: { state: 'not_ready' } }; - renderPanel(); - const start = screen.getByText('Provision'); - expect(start).not.toBeDisabled(); - fireEvent.click(start); - expect(mockMutate).toHaveBeenCalledWith('provision'); - }); - - // Pause/Resume removed; Start/Stop only per server API alignment - - it('enables Deprovision when ready', () => { - mockStatus = { isPaused: false, provisionStatus: { state: 'ready' } }; - renderPanel(); - const stop = screen.getByText('Deprovision'); - expect(stop).not.toBeDisabled(); - fireEvent.click(stop); - expect(mockMutate).toHaveBeenCalledWith('deprovision'); - }); -}); diff --git a/packages/platform-ui/src/components/graph/__tests__/NodeObsSidebar.reminders.test.tsx b/packages/platform-ui/src/components/graph/__tests__/NodeObsSidebar.reminders.test.tsx deleted file mode 100644 index b4d439929..000000000 --- a/packages/platform-ui/src/components/graph/__tests__/NodeObsSidebar.reminders.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* @vitest-environment jsdom */ -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { NodeTracingSidebar } from '../NodeTracingSidebar'; - -vi.mock('../../../lib/graph/templates.provider', () => ({ - useTemplatesCache: () => ({ getTemplate: (_name: string) => ({ kind: 'tool' }) }), -})); - -// Provide a mutable implementation we can swap per-test -let useNodeRemindersImpl: any = (_nodeId: string) => ({ isLoading: false, data: { items: [ - { id: 'r1', threadId: 't-1', note: 'Check back', at: new Date().toISOString() }, -] } }); -vi.mock('../../../lib/graph/hooks', () => ({ - useNodeReminders: (...args: any[]) => useNodeRemindersImpl(...args), -})); - -describe('NodeObsSidebar Active Reminders', () => { - const node: any = { id: 'n1', data: { template: 'remindMeTool', config: {} } }; - - it('renders Active Reminders section and items', async () => { - render(); - expect(screen.getByText('Active Reminders')).toBeInTheDocument(); - expect(screen.getByText('Check back')).toBeInTheDocument(); - // Thread id appears inside an aria-label and split text; use label to assert - expect(screen.getByLabelText('Reminder for thread t-1')).toBeInTheDocument(); - }); - - it('shows error state when hook errors', async () => { - useNodeRemindersImpl = () => ({ isLoading: false, error: new Error('boom') }); - render(); - expect(await screen.findByRole('alert')).toBeInTheDocument(); - }); -}); diff --git a/packages/platform-ui/src/components/graph/__tests__/NodeObsSidebar.terminate.ui.test.tsx b/packages/platform-ui/src/components/graph/__tests__/NodeObsSidebar.terminate.ui.test.tsx deleted file mode 100644 index a889095bc..000000000 --- a/packages/platform-ui/src/components/graph/__tests__/NodeObsSidebar.terminate.ui.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* @vitest-environment jsdom */ -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; - -// Mocks MUST be declared before importing the component under test -vi.mock('@/lib/graph/templates.provider', () => ({ - useTemplatesCache: () => ({ getTemplate: (_name: string) => ({ kind: 'agent' }) }), -})); -vi.mock('@/lib/graph/hooks', () => ({ useNodeReminders: () => ({ isLoading: false, data: { items: [] } }) })); -vi.mock('@/api/modules/graph', () => ({ - graph: { - listNodeRuns: vi.fn(async () => ({ items: [{ nodeId: 'n', threadId: 't', runId: 't/run-1', status: 'running', startedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }] })), - terminateRun: vi.fn(async () => ({ ok: true })), - }, -})); -import { NodeTracingSidebar } from '../NodeTracingSidebar'; -import { graph as api } from '@/api/modules/graph'; - -describe('NodeObsSidebar terminate UI behavior', () => { - it('renders active runs, disables button during terminate, optimistic state and refresh', async () => { - const oldConfirm = window.confirm; - // @ts-expect-error test override - window.confirm = () => true; - const node: any = { id: 'agent-1', data: { template: 'agent' } }; - await act(async () => { render(); }); - expect(await screen.findByText('Active Runs')).toBeInTheDocument(); - const btn = await screen.findByText('Terminate'); - expect(btn).toBeEnabled(); - await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(api.terminateRun).toHaveBeenCalledWith('t/run-1'); - const badge = await screen.findByText('terminating'); - expect(badge).toBeInTheDocument(); - expect(btn).toBeDisabled(); - // Next poll should run again (timer); simulate immediate refresh - (api.listNodeRuns as any).mockResolvedValueOnce({ items: [] }); - window.confirm = oldConfirm; - }); -}); diff --git a/packages/platform-ui/src/components/graph/index.ts b/packages/platform-ui/src/components/graph/index.ts deleted file mode 100644 index 3408a4c4a..000000000 --- a/packages/platform-ui/src/components/graph/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as NodeDetailsPanel } from './NodeDetailsPanel'; - 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" + /> +
+ +