diff --git a/.gitignore b/.gitignore index 6b928ec5e..d5175192d 100644 --- a/.gitignore +++ b/.gitignore @@ -210,4 +210,7 @@ go/bin/ ## Test certificates python/packages/kagent-adk/tests/fixtures/certs/*.pem -python/packages/kagent-adk/tests/fixtures/certs/*.srl \ No newline at end of file +python/packages/kagent-adk/tests/fixtures/certs/*.srl +.worktrees/ +/go/kanban-mcp +/go/gitrepo-mcp diff --git a/.reflex/config.toml b/.reflex/config.toml new file mode 100644 index 000000000..68bf22dc6 --- /dev/null +++ b/.reflex/config.toml @@ -0,0 +1,25 @@ +[index] +languages = [] # Empty = all supported languages +max_file_size = 10485760 # 10 MB +follow_symlinks = false + +[index.include] +patterns = [] + +[index.exclude] +patterns = [] + +[search] +default_limit = 100 +fuzzy_threshold = 0.8 + +[performance] +parallel_threads = 0 # 0 = auto (80% of available cores), or set a specific number +compression_level = 3 # zstd level + +[semantic] +# Semantic query generation using LLMs +# Translate natural language questions into rfx query commands +provider = "groq" # Options: openai, anthropic, groq +# model = "llama-3.3-70b-versatile" # Optional: override provider default model +# auto_execute = false # Optional: auto-execute queries without confirmation diff --git a/.reflex/content.bin b/.reflex/content.bin new file mode 100644 index 000000000..c76163cc3 Binary files /dev/null and b/.reflex/content.bin differ diff --git a/.reflex/trigrams.bin b/.reflex/trigrams.bin new file mode 100644 index 000000000..456ea730a Binary files /dev/null and b/.reflex/trigrams.bin differ diff --git a/Makefile b/Makefile index 0df22b66f..5d6ade5e9 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,11 @@ APP_IMAGE_NAME ?= app KAGENT_ADK_IMAGE_NAME ?= kagent-adk GOLANG_ADK_IMAGE_NAME ?= golang-adk SKILLS_INIT_IMAGE_NAME ?= skills-init +KANBAN_MCP_IMAGE_NAME ?= kanban-mcp +GITREPO_MCP_IMAGE_NAME ?= gitrepo-mcp +TEMPORAL_MCP_IMAGE_NAME ?= temporal-mcp +NATS_ACTIVITY_FEED_IMAGE_NAME ?= nats-activity-feed +CRON_MCP_IMAGE_NAME ?= cron-mcp CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) @@ -44,6 +49,11 @@ APP_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) GOLANG_ADK_IMAGE_TAG ?= $(VERSION) SKILLS_INIT_IMAGE_TAG ?= $(VERSION) +KANBAN_MCP_IMAGE_TAG ?= $(VERSION) +GITREPO_MCP_IMAGE_TAG ?= $(VERSION) +TEMPORAL_MCP_IMAGE_TAG ?= $(VERSION) +NATS_ACTIVITY_FEED_IMAGE_TAG ?= $(VERSION) +CRON_MCP_IMAGE_TAG ?= $(VERSION) CONTROLLER_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CONTROLLER_IMAGE_NAME):$(CONTROLLER_IMAGE_TAG) UI_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(UI_IMAGE_NAME):$(UI_IMAGE_TAG) @@ -51,6 +61,11 @@ APP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(APP_IMAGE_NAME):$(APP_IMAGE_TAG) KAGENT_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KAGENT_ADK_IMAGE_NAME):$(KAGENT_ADK_IMAGE_TAG) GOLANG_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(GOLANG_ADK_IMAGE_NAME):$(GOLANG_ADK_IMAGE_TAG) SKILLS_INIT_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SKILLS_INIT_IMAGE_NAME):$(SKILLS_INIT_IMAGE_TAG) +KANBAN_MCP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KANBAN_MCP_IMAGE_NAME):$(KANBAN_MCP_IMAGE_TAG) +GITREPO_MCP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(GITREPO_MCP_IMAGE_NAME):$(GITREPO_MCP_IMAGE_TAG) +TEMPORAL_MCP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(TEMPORAL_MCP_IMAGE_NAME):$(TEMPORAL_MCP_IMAGE_TAG) +NATS_ACTIVITY_FEED_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(NATS_ACTIVITY_FEED_IMAGE_NAME):$(NATS_ACTIVITY_FEED_IMAGE_TAG) +CRON_MCP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CRON_MCP_IMAGE_NAME):$(CRON_MCP_IMAGE_TAG) #take from go/core/go.mod AWK ?= $(shell command -v gawk || command -v awk) @@ -217,12 +232,10 @@ prune-docker-images: docker images --filter dangling=true -q | xargs -r docker rmi || : .PHONY: build -build: buildx-create build-controller build-ui build-app build-golang-adk build-skills-init +build: buildx-create build-controller build-ui build-golang-adk build-skills-init build-kanban-mcp build-gitrepo-mcp build-temporal-mcp build-nats-activity-feed build-cron-mcp @echo "Build completed successfully." @echo "Controller Image: $(CONTROLLER_IMG)" @echo "UI Image: $(UI_IMG)" - @echo "App Image: $(APP_IMG)" - @echo "Kagent ADK Image: $(KAGENT_ADK_IMG)" @echo "Golang ADK Image: $(GOLANG_ADK_IMG)" @echo "Skills Init Image: $(SKILLS_INIT_IMG)" @@ -253,7 +266,7 @@ lint: make -C python lint .PHONY: push -push: push-controller push-ui push-app push-kagent-adk push-golang-adk +push: push-controller push-ui push-golang-adk .PHONY: controller-manifests @@ -283,7 +296,27 @@ build-golang-adk: buildx-create .PHONY: build-skills-init build-skills-init: buildx-create - $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) -t $(SKILLS_INIT_IMG) -f docker/skills-init/Dockerfile docker/skills-init + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) --build-arg BASE_IMAGE_REGISTRY=$(BASE_IMAGE_REGISTRY) -t $(SKILLS_INIT_IMG) -f docker/skills-init/Dockerfile docker/skills-init + +.PHONY: build-kanban-mcp +build-kanban-mcp: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=./plugins/kanban-mcp/ -t $(KANBAN_MCP_IMG) -f go/Dockerfile ./go + +.PHONY: build-gitrepo-mcp +build-gitrepo-mcp: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(GITREPO_MCP_IMG) -f go/plugins/gitrepo-mcp/Dockerfile ./go + +.PHONY: build-temporal-mcp +build-temporal-mcp: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=./plugins/temporal-mcp/ -t $(TEMPORAL_MCP_IMG) -f go/Dockerfile ./go + +.PHONY: build-nats-activity-feed +build-nats-activity-feed: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=./plugins/nats-activity-feed/ -t $(NATS_ACTIVITY_FEED_IMG) -f go/Dockerfile ./go + +.PHONY: build-cron-mcp +build-cron-mcp: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=./plugins/cron-mcp/ -t $(CRON_MCP_IMG) -f go/Dockerfile ./go .PHONY: helm-cleanup helm-cleanup: @@ -329,6 +362,12 @@ helm-tools: helm package -d $(HELM_DIST_FOLDER) helm/tools/grafana-mcp VERSION=$(VERSION) envsubst < helm/tools/querydoc/Chart-template.yaml > helm/tools/querydoc/Chart.yaml helm package -d $(HELM_DIST_FOLDER) helm/tools/querydoc + VERSION=$(VERSION) envsubst < helm/tools/kanban-mcp/Chart-template.yaml > helm/tools/kanban-mcp/Chart.yaml + helm package -d $(HELM_DIST_FOLDER) helm/tools/kanban-mcp + VERSION=$(VERSION) envsubst < helm/tools/gitrepo-mcp/Chart-template.yaml > helm/tools/gitrepo-mcp/Chart.yaml + helm package -d $(HELM_DIST_FOLDER) helm/tools/gitrepo-mcp + VERSION=$(VERSION) envsubst < helm/tools/cron-mcp/Chart-template.yaml > helm/tools/cron-mcp/Chart.yaml + helm package -d $(HELM_DIST_FOLDER) helm/tools/cron-mcp .PHONY: helm-version helm-version: helm-cleanup helm-agents helm-tools @@ -371,7 +410,25 @@ helm-install-provider: helm-version check-api-key --set providers.default=$(KAGENT_DEFAULT_MODEL_PROVIDER) \ --set kmcp.enabled=$(KMCP_ENABLED) \ --set kmcp.image.tag=$(KMCP_VERSION) \ + --set tools.kanban-mcp.enabled=true \ + --set kanban-mcp.image.registry=$(DOCKER_REGISTRY) \ + --set kanban-mcp.image.repository=$(DOCKER_REPO)/$(KANBAN_MCP_IMAGE_NAME) \ + --set kanban-mcp.image.tag=$(KANBAN_MCP_IMAGE_TAG) \ + --set-string kanban-mcp.config.KANBAN_DB_PATH=/tmp/kanban.db \ + --set tools.gitrepo-mcp.enabled=true \ + --set gitrepo-mcp.image.registry=$(DOCKER_REGISTRY) \ + --set gitrepo-mcp.image.repository=$(DOCKER_REPO)/$(GITREPO_MCP_IMAGE_NAME) \ + --set gitrepo-mcp.image.tag=$(GITREPO_MCP_IMAGE_TAG) \ + --set gitrepo-mcp.args[0]=serve \ + --set-string gitrepo-mcp.config.GITREPO_ADDR=:8080 \ + --set-string gitrepo-mcp.config.GITREPO_DATA_DIR=/tmp/gitrepo \ --set querydoc.openai.apiKey=$(OPENAI_API_KEY) \ + --set temporal.mcp.image=$(TEMPORAL_MCP_IMG) \ + --set tools.cron-mcp.enabled=true \ + --set cron-mcp.image.registry=$(DOCKER_REGISTRY) \ + --set cron-mcp.image.repository=$(DOCKER_REPO)/$(CRON_MCP_IMAGE_NAME) \ + --set cron-mcp.image.tag=$(CRON_MCP_IMAGE_TAG) \ + --set-string cron-mcp.config.CRON_DB_PATH=/tmp/cron.db \ $(KAGENT_HELM_EXTRA_ARGS) .PHONY: helm-install @@ -417,6 +474,11 @@ kagent-ui-port-forward: use-kind-cluster open http://localhost:8082/ kubectl port-forward -n kagent service/kagent-ui 8082:8080 +.PHONY: temporal-ui-port-forward +temporal-ui-port-forward: use-kind-cluster + open http://localhost:8084/ + kubectl port-forward -n kagent service/kagent-temporal-ui 8084:8080 + .PHONY: kagent-addon-install kagent-addon-install: use-kind-cluster # to test the kagent addons - installing istio, grafana, prometheus, metrics-server @@ -449,6 +511,31 @@ kind-debug: docker exec -it $(KIND_CLUSTER_NAME)-control-plane bash -c 'apt-get update && apt-get install -y btop htop' docker exec -it $(KIND_CLUSTER_NAME)-control-plane bash -c 'btop --utf-force' +##@ Testing + +.PHONY: test-e2e-plugins +test-e2e-plugins: ## Run plugin API + proxy smoke test against running cluster + bash scripts/check-plugins-api.sh --wait --proxy \ + --url "$(KAGENT_URL)/api/plugins" \ + --proxy-base-url "$(KAGENT_URL)" \ + --plugin "$(PLUGIN_PATH_PREFIX)" \ + --section "$(PLUGIN_SECTION)" + +KAGENT_URL ?= http://localhost:8083 +PLUGIN_PATH_PREFIX ?= kanban-mcp +PLUGIN_SECTION ?= AGENTS + +.PHONY: test-e2e-go +test-e2e-go: ## Run Go E2E tests + make -C go e2e + +.PHONY: test-e2e-browser +test-e2e-browser: ## Run Cypress browser E2E tests for plugin routing + cd ui && npx cypress run --spec cypress/e2e/plugin-routing.cy.ts + +.PHONY: test-e2e-all +test-e2e-all: test-e2e-go test-e2e-plugins test-e2e-browser ## Run all E2E tests (Go + plugin smoke + browser) + .PHONY: audit audit: echo "Running CVE audit GO" diff --git a/contrib/tools/gitrepo-mcp/Chart.yaml b/contrib/tools/gitrepo-mcp/Chart.yaml new file mode 100644 index 000000000..8cc89011d --- /dev/null +++ b/contrib/tools/gitrepo-mcp/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: gitrepo-mcp +description: Git repo MCP server for kagent — semantic search + ast-grep structural search via MCP tools + REST API +type: application +version: 0.1.0 +appVersion: latest +sources: + - https://github.com/kagent-dev/kagent diff --git a/contrib/tools/gitrepo-mcp/templates/_helpers.tpl b/contrib/tools/gitrepo-mcp/templates/_helpers.tpl new file mode 100644 index 000000000..60ba0b253 --- /dev/null +++ b/contrib/tools/gitrepo-mcp/templates/_helpers.tpl @@ -0,0 +1,56 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gitrepo-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "gitrepo-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gitrepo-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gitrepo-mcp.labels" -}} +helm.sh/chart: {{ include "gitrepo-mcp.chart" . }} +{{ include "gitrepo-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gitrepo-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gitrepo-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the gitrepo mcp server URL. +*/}} +{{- define "gitrepo-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "gitrepo-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} diff --git a/contrib/tools/gitrepo-mcp/templates/cronjob.yaml b/contrib/tools/gitrepo-mcp/templates/cronjob.yaml new file mode 100644 index 000000000..c64fe4f19 --- /dev/null +++ b/contrib/tools/gitrepo-mcp/templates/cronjob.yaml @@ -0,0 +1,52 @@ +{{- if .Values.cronJob.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "gitrepo-mcp.fullname" . }}-sync + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.cronJob.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: {{ .Values.cronJob.successfulJobsHistoryLimit | default 3 }} + failedJobsHistoryLimit: {{ .Values.cronJob.failedJobsHistoryLimit | default 1 }} + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 12 }} + spec: + restartPolicy: OnFailure + containers: + - name: sync + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["gitrepo-mcp"] + args: + - "sync-all" + - "--reindex={{ .Values.cronJob.reindex }}" + env: + - name: GITREPO_DB_TYPE + value: {{ .Values.config.dbType | quote }} + - name: GITREPO_DB_PATH + value: {{ .Values.config.dbPath | quote }} + - name: GITREPO_DATA_DIR + value: {{ .Values.config.dataDir | quote }} + {{- if .Values.config.dbUrl }} + - name: GITREPO_DB_URL + value: {{ .Values.config.dbUrl | quote }} + {{- end }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: data + mountPath: /data + {{- end }} + {{- if .Values.persistence.enabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "gitrepo-mcp.fullname" . }} + {{- end }} +{{- end }} diff --git a/contrib/tools/gitrepo-mcp/templates/deployment.yaml b/contrib/tools/gitrepo-mcp/templates/deployment.yaml new file mode 100644 index 000000000..e38274b00 --- /dev/null +++ b/contrib/tools/gitrepo-mcp/templates/deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: gitrepo-mcp + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["gitrepo-mcp"] + args: ["serve"] + env: + - name: GITREPO_ADDR + value: {{ .Values.config.addr | quote }} + - name: GITREPO_TRANSPORT + value: {{ .Values.config.transport | quote }} + - name: GITREPO_DB_TYPE + value: {{ .Values.config.dbType | quote }} + - name: GITREPO_DB_PATH + value: {{ .Values.config.dbPath | quote }} + - name: GITREPO_DATA_DIR + value: {{ .Values.config.dataDir | quote }} + - name: GITREPO_LOG_LEVEL + value: {{ .Values.config.logLevel | quote }} + {{- if .Values.config.dbUrl }} + - name: GITREPO_DB_URL + value: {{ .Values.config.dbUrl | quote }} + {{- end }} + ports: + - name: http + containerPort: 8090 + protocol: TCP + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: data + mountPath: /data + {{- end }} + {{- if .Values.persistence.enabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "gitrepo-mcp.fullname" . }} + {{- end }} diff --git a/contrib/tools/gitrepo-mcp/templates/pvc.yaml b/contrib/tools/gitrepo-mcp/templates/pvc.yaml new file mode 100644 index 000000000..11c67dcf3 --- /dev/null +++ b/contrib/tools/gitrepo-mcp/templates/pvc.yaml @@ -0,0 +1,18 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} +{{- end }} diff --git a/contrib/tools/gitrepo-mcp/templates/remotemcpserver.yaml b/contrib/tools/gitrepo-mcp/templates/remotemcpserver.yaml new file mode 100644 index 000000000..a96fee68e --- /dev/null +++ b/contrib/tools/gitrepo-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,20 @@ +{{- if .Values.remoteMCPServer.enabled }} +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + description: {{ .Values.remoteMCPServer.description | quote }} + protocol: {{ .Values.remoteMCPServer.protocol }} + sseReadTimeout: {{ .Values.remoteMCPServer.sseReadTimeout }} + terminateOnClose: {{ .Values.remoteMCPServer.terminateOnClose }} + timeout: {{ .Values.remoteMCPServer.timeout }} + url: {{ include "gitrepo-mcp.serverUrl" . }} + {{- if .Values.remoteMCPServer.allowedNamespaces }} + allowedNamespaces: + {{- toYaml .Values.remoteMCPServer.allowedNamespaces | nindent 4 }} + {{- end }} +{{- end }} diff --git a/contrib/tools/gitrepo-mcp/templates/service.yaml b/contrib/tools/gitrepo-mcp/templates/service.yaml new file mode 100644 index 000000000..b0ed98dd3 --- /dev/null +++ b/contrib/tools/gitrepo-mcp/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 4 }} diff --git a/contrib/tools/gitrepo-mcp/values.yaml b/contrib/tools/gitrepo-mcp/values.yaml new file mode 100644 index 000000000..8f3aaec79 --- /dev/null +++ b/contrib/tools/gitrepo-mcp/values.yaml @@ -0,0 +1,47 @@ +replicaCount: 1 + +image: + repository: ghcr.io/kagent-dev/gitrepo-mcp + pullPolicy: IfNotPresent + tag: "latest" + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 8090 + +config: + addr: ":8090" + transport: "http" + dbType: "sqlite" + dbPath: "/data/gitrepo.db" + # dbUrl: "" # set when dbType=postgres + dataDir: "/data" + logLevel: "info" + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + +resources: {} + +cronJob: + enabled: false + schedule: "0 */6 * * *" + reindex: true + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + +remoteMCPServer: + enabled: true + description: "Git repo MCP server — semantic and structural code search" + protocol: STREAMABLE_HTTP + timeout: 30s + sseReadTimeout: 5m0s + terminateOnClose: true + allowedNamespaces: + from: All diff --git a/contrib/tools/kanban-mcp/Chart.yaml b/contrib/tools/kanban-mcp/Chart.yaml new file mode 100644 index 000000000..ba3f1a2cb --- /dev/null +++ b/contrib/tools/kanban-mcp/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: kanban-mcp +description: Kanban MCP server for kagent — task management via MCP tools + REST API + embedded UI +type: application +version: 0.1.0 +appVersion: latest +sources: + - https://github.com/kagent-dev/kagent diff --git a/contrib/tools/kanban-mcp/README.md b/contrib/tools/kanban-mcp/README.md new file mode 100644 index 000000000..812b7f357 --- /dev/null +++ b/contrib/tools/kanban-mcp/README.md @@ -0,0 +1,114 @@ +# kanban-mcp Helm Chart + +Helm chart for deploying the `kanban-mcp` server into Kubernetes. + +The server exposes: +- HTTP API +- MCP endpoint +- Embedded board UI +- SQLite (default) or Postgres-backed storage + +Default image: `ghcr.io/kagent-dev/kanban-mcp:latest` +Default service port: `8080` +Default namespace in examples: `kagent` + +## Chart Location + +From repo root: + +`contrib/tools/kanban-mcp` + +## Quick Start + +Install: + +```bash +helm upgrade --install kanban-mcp ./contrib/tools/kanban-mcp \ + -n kagent \ + --create-namespace \ + --wait --timeout 5m +``` + +Check release: + +```bash +helm status kanban-mcp -n kagent +``` + +Uninstall: + +```bash +helm uninstall kanban-mcp -n kagent +``` + +## Local Development Image Workflow (Kind) + +If you built the image locally and want the cluster to use it: + +```bash +# from repo root +docker build -f go/cmd/kanban-mcp/Dockerfile \ + -t ghcr.io/kagent-dev/kanban-mcp:latest . + +kind load docker-image ghcr.io/kagent-dev/kanban-mcp:latest --name kagent + +helm upgrade --install kanban-mcp ./contrib/tools/kanban-mcp \ + -n kagent \ + --create-namespace \ + --wait --timeout 5m +``` + +## Configuration + +Primary values in `values.yaml`: + +| Key | Default | Description | +|---|---|---| +| `replicaCount` | `1` | Number of pod replicas | +| `image.repository` | `ghcr.io/kagent-dev/kanban-mcp` | Container image repository | +| `image.tag` | `latest` | Container image tag | +| `image.pullPolicy` | `IfNotPresent` | Pull policy | +| `service.type` | `ClusterIP` | Kubernetes service type | +| `service.port` | `8080` | Service port | +| `config.addr` | `:8080` | Server bind address (`KANBAN_ADDR`) | +| `config.transport` | `http` | Transport mode (`KANBAN_TRANSPORT`) | +| `config.dbType` | `sqlite` | `sqlite` or `postgres` (`KANBAN_DB_TYPE`) | +| `config.dbPath` | `/data/kanban.db` | SQLite DB file path (`KANBAN_DB_PATH`) | +| `config.dbUrl` | _empty_ | Postgres DSN (`KANBAN_DB_URL`) | +| `config.logLevel` | `info` | Log level (`KANBAN_LOG_LEVEL`) | +| `persistence.enabled` | `true` | Creates PVC and mounts `/data` | +| `persistence.accessMode` | `ReadWriteOnce` | PVC access mode | +| `persistence.size` | `1Gi` | PVC requested size | +| `persistence.storageClass` | _empty_ | Optional PVC storageClass | +| `remoteMCPServer.enabled` | `true` | Creates a `RemoteMCPServer` for kagent runtime discovery | +| `remoteMCPServer.protocol` | `STREAMABLE_HTTP` | Remote MCP transport protocol | +| `remoteMCPServer.timeout` | `30s` | Per-request timeout for remote MCP calls | +| `remoteMCPServer.sseReadTimeout` | `5m0s` | Read timeout used for SSE transport | +| `remoteMCPServer.terminateOnClose` | `true` | Whether to terminate stream session on close | +| `remoteMCPServer.allowedNamespaces.from` | `All` | Namespace policy for cross-namespace references (`All`, `Same`, `Selector`) | + +### Use Postgres + +Pass overrides: + +```bash +helm upgrade --install kanban-mcp ./contrib/tools/kanban-mcp \ + -n kagent \ + --set config.dbType=postgres \ + --set config.dbUrl='postgres://user:pass@host:5432/kanban?sslmode=disable' \ + --set persistence.enabled=false \ + --wait --timeout 5m +``` + +## Notes + +- The chart names resources using `-kanban-mcp` by default. +- When `persistence.enabled=true`, the chart creates a PVC with the same computed name as the deployment/service. +- Environment variables are injected from `values.yaml` into the container (`KANBAN_*`). +- The chart also creates a `RemoteMCPServer` pointing at `http://-kanban-mcp.:/mcp` so kagent can invoke kanban tools through runtime MCP. + +## Troubleshooting + +- **Build fails with Go version error**: ensure the Dockerfile builder image uses Go `1.25+` (the module requires `go >= 1.25.7`). +- **Pod keeps using old image in Kind**: rebuild image, run `kind load docker-image ...`, then redeploy with `helm upgrade --install`. +- **Release not ready**: inspect Helm state with `helm status kanban-mcp -n kagent` and `helm get all kanban-mcp -n kagent`. diff --git a/contrib/tools/kanban-mcp/templates/_helpers.tpl b/contrib/tools/kanban-mcp/templates/_helpers.tpl new file mode 100644 index 000000000..a6890fbca --- /dev/null +++ b/contrib/tools/kanban-mcp/templates/_helpers.tpl @@ -0,0 +1,56 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kanban-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "kanban-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kanban-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kanban-mcp.labels" -}} +helm.sh/chart: {{ include "kanban-mcp.chart" . }} +{{ include "kanban-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kanban-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kanban-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the kanban mcp server URL. +*/}} +{{- define "kanban-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "kanban-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} diff --git a/contrib/tools/kanban-mcp/templates/background-code-agent.yaml b/contrib/tools/kanban-mcp/templates/background-code-agent.yaml new file mode 100644 index 000000000..f8fd2538e --- /dev/null +++ b/contrib/tools/kanban-mcp/templates/background-code-agent.yaml @@ -0,0 +1,52 @@ +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: sdlc-code-agent + namespace: kagent +spec: + declarative: + modelConfig: default-model-config + stream: true + systemMessage: |- + You're a helpful coding agent, made by the kagent team. + You can write code and work with a kanban board to manage your work. + You have access to the following tools to help you with your work: + - assign_task: Assign a task to a user + - create_subtask: Create a subtask under a parent task + - create_task: Create a new task + - add_attachment: Add a file or link attachment to a task + - delete_attachment: Delete an attachment by ID + + # Instructions + - If user question is unclear, ask for clarification before running any tools + - Always be helpful and friendly + - If you don't know how to answer the question DO NOT make things up, tell the user "Sorry, I don't know how to answer that" and ask them to clarify the question further + - If you are unable to help, or something goes wrong, refer the user to https://kagent.dev for more information or support. + + # Response format: + - ALWAYS format your response as Markdown + - List of tasks should be formatted as a Markdown table with columns + - Your response will include a summary of actions you took and an explanation of the result + - If you created any artifacts such as files or resources, you will include those in your response as well + tools: + - mcpServer: + apiGroup: kagent.dev + kind: RemoteMCPServer + name: kanban-mcp + namespace: kagent + toolNames: + - assign_task + - create_subtask + - create_task + - delete_task + - get_board + - get_task + - list_tasks + - move_task + - set_user_input_needed + - update_task + - add_attachment + - delete_attachment + type: McpServer + description: You are helpful coding agent + type: Declarative diff --git a/contrib/tools/kanban-mcp/templates/deployment.yaml b/contrib/tools/kanban-mcp/templates/deployment.yaml new file mode 100644 index 000000000..18dc2795e --- /dev/null +++ b/contrib/tools/kanban-mcp/templates/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "kanban-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "kanban-mcp.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: kanban-mcp + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: KANBAN_ADDR + value: {{ .Values.config.addr | quote }} + - name: KANBAN_TRANSPORT + value: {{ .Values.config.transport | quote }} + - name: KANBAN_DB_TYPE + value: {{ .Values.config.dbType | quote }} + - name: KANBAN_DB_PATH + value: {{ .Values.config.dbPath | quote }} + - name: KANBAN_LOG_LEVEL + value: {{ .Values.config.logLevel | quote }} + {{- if .Values.config.dbUrl }} + - name: KANBAN_DB_URL + value: {{ .Values.config.dbUrl | quote }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: data + mountPath: /data + {{- end }} + {{- if .Values.persistence.enabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "kanban-mcp.fullname" . }} + {{- end }} diff --git a/contrib/tools/kanban-mcp/templates/pvc.yaml b/contrib/tools/kanban-mcp/templates/pvc.yaml new file mode 100644 index 000000000..de512adb0 --- /dev/null +++ b/contrib/tools/kanban-mcp/templates/pvc.yaml @@ -0,0 +1,18 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} +{{- end }} diff --git a/contrib/tools/kanban-mcp/templates/remotemcpserver.yaml b/contrib/tools/kanban-mcp/templates/remotemcpserver.yaml new file mode 100644 index 000000000..2b3e36c82 --- /dev/null +++ b/contrib/tools/kanban-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,20 @@ +{{- if .Values.remoteMCPServer.enabled }} +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + description: {{ .Values.remoteMCPServer.description | quote }} + protocol: {{ .Values.remoteMCPServer.protocol }} + sseReadTimeout: {{ .Values.remoteMCPServer.sseReadTimeout }} + terminateOnClose: {{ .Values.remoteMCPServer.terminateOnClose }} + timeout: {{ .Values.remoteMCPServer.timeout }} + url: {{ include "kanban-mcp.serverUrl" . }} + {{- if .Values.remoteMCPServer.allowedNamespaces }} + allowedNamespaces: + {{- toYaml .Values.remoteMCPServer.allowedNamespaces | nindent 4 }} + {{- end }} +{{- end }} diff --git a/contrib/tools/kanban-mcp/templates/service.yaml b/contrib/tools/kanban-mcp/templates/service.yaml new file mode 100644 index 000000000..0d0923283 --- /dev/null +++ b/contrib/tools/kanban-mcp/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "kanban-mcp.selectorLabels" . | nindent 4 }} diff --git a/contrib/tools/kanban-mcp/values.yaml b/contrib/tools/kanban-mcp/values.yaml new file mode 100644 index 000000000..0d46fb160 --- /dev/null +++ b/contrib/tools/kanban-mcp/values.yaml @@ -0,0 +1,39 @@ +replicaCount: 1 + +image: + repository: ghcr.io/kagent-dev/kanban-mcp + pullPolicy: IfNotPresent + tag: "latest" + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 8080 + +config: + addr: ":8080" + transport: "http" + dbType: "sqlite" + dbPath: "/data/kanban.db" + # dbUrl: "" # set when dbType=postgres, e.g. "host=pg user=postgres password=secret dbname=kanban port=5432 sslmode=disable" + logLevel: "info" + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 1Gi + +resources: {} + +remoteMCPServer: + enabled: true + description: "Kanban MCP server" + protocol: STREAMABLE_HTTP + timeout: 30s + sseReadTimeout: 5m0s + terminateOnClose: true + allowedNamespaces: + from: All diff --git a/design/EP-2001-mcp-kanban-server.md b/design/EP-2001-mcp-kanban-server.md new file mode 100644 index 000000000..6b2e2eb09 --- /dev/null +++ b/design/EP-2001-mcp-kanban-server.md @@ -0,0 +1,38 @@ +# EP-2001: MCP Kanban Server + +* Status: **Implemented** +* Spec: [specs/mcp-kanban-server](../specs/mcp-kanban-server/) + +## Background + +Self-contained Go binary (`go/plugins/kanban-mcp/`) serving four surfaces on a single port: MCP Server (12 tools), REST API (CRUD), SSE endpoint (real-time push), and embedded SPA (vanilla HTML+JS). Provides task management for AI agents with human-in-the-loop support. + +## Motivation + +Agents need a persistent task board to track work items across workflow stages. The kanban board enables both AI agents (via MCP tools) and humans (via web UI) to manage tasks collaboratively with real-time updates. + +### Goals + +- 12 MCP tools: list/get/create/update/delete tasks, subtasks, attachments, board view, HITL flag +- 7-stage workflow: Inbox → Plan → Develop → Testing → CodeReview → Release → Done +- Real-time SSE updates with zero external dependencies +- Embedded vanilla HTML+JS SPA with no build step +- Dual database support: SQLite (dev) / PostgreSQL (prod) via GORM + +### Non-Goals + +- Multi-board support +- Drag-and-drop UI +- Deep subtask nesting (1 level only) + +## Implementation Details + +- **Binary:** `go/plugins/kanban-mcp/` +- **Database:** GORM with Task + Attachment models, `//go:embed` for UI +- **Protocols:** MCP Streamable HTTP at `/mcp`, REST at `/api/*`, SSE at `/events` +- **Deployment:** Helm sub-chart, registered as RemoteMCPServer CRD with UI plugin metadata + +### Test Plan + +- Unit tests per package (`service/`, `api/`, `mcp/`, `sse/`, `config/`) +- Postgres integration test with `KANBAN_TEST_POSTGRES_URL` diff --git a/design/EP-2002-pluggable-ui-sidebar.md b/design/EP-2002-pluggable-ui-sidebar.md new file mode 100644 index 000000000..7043d9676 --- /dev/null +++ b/design/EP-2002-pluggable-ui-sidebar.md @@ -0,0 +1,35 @@ +# EP-2002: Pluggable UI Left Sidebar + +* Status: **Implemented** +* Spec: [specs/pluggable-ui-k8s-plugins](../specs/pluggable-ui-k8s-plugins/) + +## Background + +Replace the KAgent top-nav Header with a persistent left sidebar built on shadcn/ui primitives. Provides grouped navigation, Kubernetes namespace selector, and plugin discovery. + +## Motivation + +The top navigation bar doesn't scale with growing number of pages and plugins. A left sidebar provides grouped sections, collapse-to-icons mode, and dynamic plugin entries. + +### Goals + +- Grouped navigation: OVERVIEW / AGENTS / RESOURCES / ADMIN sections +- Kubernetes namespace selector in sidebar header +- Collapse-to-icons mode and mobile sheet overlay +- Dynamic plugin entries discovered via `/api/plugins` + +### Non-Goals + +- Multi-level nested navigation +- Drag-and-drop sidebar customization + +## Implementation Details + +- **Components:** `AppSidebar`, `SidebarProvider`, `SidebarInset` from shadcn/ui +- **Layout:** Chat's `SessionsSidebar` moved to `side="right"`, `AgentDetailsSidebar` becomes a `Sheet` +- **Files:** `ui/src/components/sidebars/AppSidebar.tsx`, `ui/src/app/layout.tsx` + +### Test Plan + +- Visual regression testing +- Mobile responsive layout verification diff --git a/design/EP-2003-agent-cron-jobs.md b/design/EP-2003-agent-cron-jobs.md new file mode 100644 index 000000000..c7f6d977d --- /dev/null +++ b/design/EP-2003-agent-cron-jobs.md @@ -0,0 +1,39 @@ +# EP-2003: AgentCronJob — Scheduled AI Agent Execution + +* Status: **Implemented** +* Spec: [specs/ai-cron-jobs](../specs/ai-cron-jobs/) + +## Background + +New Kubernetes CRD (`AgentCronJob`, `kagent.dev/v1alpha2`) that schedules AI agent prompt execution on a cron schedule. References an existing Agent CR and sends a static prompt at each tick via the kagent HTTP API, storing results in sessions. + +## Motivation + +Users need to run agent tasks on recurring schedules (e.g., daily cluster health checks, periodic report generation) without manual intervention. + +### Goals + +- Minimal CRD spec: `schedule` + `prompt` + `agentRef` +- RequeueAfter-based scheduling (no in-memory cron library, survives restarts) +- Reuse existing session/task/event DB models +- HTTP server CRUD at `/api/cronjobs` +- UI page for listing, creating, editing, and deleting cron jobs + +### Non-Goals + +- Complex scheduling (e.g., dependencies between jobs) +- Parameterized prompts with templating +- Job history retention policies + +## Implementation Details + +- **CRD:** `go/api/v1alpha2/agentcronjob_types.go` +- **Controller:** Reconciler with RequeueAfter scheduling using `robfig/cron/v3` for parsing +- **API:** `go/core/internal/httpserver/handlers/cronjobs.go` +- **UI:** `ui/src/app/cronjobs/page.tsx` +- **Status fields:** `lastRunTime`, `nextRunTime`, `lastRunResult`, `lastRunMessage`, `lastSessionID` + +### Test Plan + +- E2E tests for CRD lifecycle +- Unit tests for schedule parsing and next-run calculation diff --git a/design/EP-2004-dynamic-mcp-ui-routing.md b/design/EP-2004-dynamic-mcp-ui-routing.md new file mode 100644 index 000000000..e148708dc --- /dev/null +++ b/design/EP-2004-dynamic-mcp-ui-routing.md @@ -0,0 +1,39 @@ +# EP-2004: Dynamic MCP UI Plugin Routing + +* Status: **Implemented** +* Spec: [specs/dynamic-mcp-ui-routing](../specs/dynamic-mcp-ui-routing/) + +## Background + +Replaces hardcoded nginx proxy rules and static Next.js routes for MCP plugin UIs with a fully dynamic, CRD-driven system. Plugin UIs are discovered from RemoteMCPServer CRD metadata, stored in a Plugin database table, and served via Go reverse proxy. + +## Motivation + +Adding a new plugin UI previously required modifying nginx config, adding Next.js routes, and redeploying. The dynamic system allows plugins to self-register their UI via CRD annotations. + +### Goals + +- CRD declares UI metadata: `pathPrefix`, `displayName`, `icon`, `section` +- Controller reconciles UI metadata into `Plugin` DB table +- Go reverse proxy at `/_p/{name}/` routes dynamically based on DB lookup +- Next.js catch-all `/plugins/[name]/` renders iframe with postMessage bridge +- Sidebar auto-discovers plugins via `GET /api/plugins` +- Iframe bridge: theme sync, resize, navigation, auth token, badges + +### Non-Goals + +- Server-side rendering of plugin content +- Plugin marketplace or versioning + +## Implementation Details + +- **Proxy:** `go/core/internal/httpserver/handlers/pluginproxy.go` — `/_p/{name}/` reverse proxy +- **DB:** `Plugin` model in `go/api/database/models.go` +- **Reconciler:** `reconcilePluginUI()` in shared reconciler +- **UI:** `ui/src/app/plugins/[name]/[[...path]]/page.tsx` — iframe host with postMessage bridge +- **Discovery:** `GET /api/plugins` returns registered plugins for sidebar + +### Test Plan + +- Cypress browser E2E tests with 8 scenarios +- Plugin loading, error, and retry state testing diff --git a/design/EP-2005-temporal-agent-workflow.md b/design/EP-2005-temporal-agent-workflow.md new file mode 100644 index 000000000..69ed47997 --- /dev/null +++ b/design/EP-2005-temporal-agent-workflow.md @@ -0,0 +1,41 @@ +# EP-2005: Temporal Integration for Durable Agent Execution + +* Status: **Implemented** +* Spec: [specs/temporal-agent-workflow](../specs/temporal-agent-workflow/) + +## Background + +Integrate Temporal as a durable workflow executor for kagent's Go ADK. Each agent execution becomes a Temporal workflow with per-turn LLM activities and per-call tool activities, providing crash recovery, retry policies, and execution history. + +## Motivation + +Agent executions can be long-running (hours) and involve multiple LLM calls and tool invocations. Without durable execution, a pod restart loses all progress. Temporal provides automatic recovery, configurable retries, and workflow visibility. + +### Goals + +- Per-turn LLM activities and per-call tool activities in Temporal workflows +- Real-time token streaming via NATS pub/sub +- Human-in-the-loop via Temporal signals +- Per-agent task queues (`agent-{name}`) +- Per-agent CRD spec control (`Agent.spec.temporal`) +- Self-hosted Temporal: SQLite for dev, PostgreSQL for prod +- 48h default workflow timeout with configurable retry policies + +### Non-Goals + +- Multi-cluster Temporal deployment +- Temporal Cloud integration (self-hosted only) +- Custom Temporal UI (separate EP-2007) + +## Implementation Details + +- **CRD:** `TemporalSpec` in `go/api/v1alpha2/agent_types.go` — `enabled`, `workflowTimeout`, `retryPolicy` +- **Worker:** In-process alongside A2A server in agent pod +- **Streaming:** NATS fire-and-forget pub/sub for token streaming +- **Translator:** Injects `TEMPORAL_HOST_ADDR` and `NATS_ADDR` env vars +- **Helm:** Temporal Server + NATS deployed via `helm-install-temporal` target + +### Test Plan + +- E2E tests: Temporal Server/NATS deployment, env var injection, workflow execution, crash recovery +- Configurable timeout and retry policy tests diff --git a/design/EP-2006-git-repos-api-ui.md b/design/EP-2006-git-repos-api-ui.md new file mode 100644 index 000000000..a7a30de02 --- /dev/null +++ b/design/EP-2006-git-repos-api-ui.md @@ -0,0 +1,40 @@ +# EP-2006: Git Repos API and UI with Code Search + +* Status: **Implemented** +* Spec: [specs/git-repos-api-ui](../specs/git-repos-api-ui/) + +## Background + +Standalone Go MCP server (`gitrepo-mcp`) that clones git repositories and provides code search capabilities. Proxied through the kagent controller API with a dedicated UI page for repository management. + +## Motivation + +AI agents need access to source code for context-aware assistance. A managed git repo service provides cloning, indexing, and search capabilities accessible via both MCP tools and REST API. + +### Goals + +- Clone and manage git repositories via REST API +- Full-text search, symbol search, regex search across repos +- MCP tools for agent-driven code search +- UI page for adding, syncing, and searching repositories +- Controller proxy integration at `/api/gitrepos` + +### Non-Goals + +- Embedding/semantic search (v2) +- LLM-based natural language code search (v2) +- FalkorDB code graph integration (v2) + +## Implementation Details + +- **Binary:** `go/plugins/gitrepo-mcp/` — standalone Go server +- **API proxy:** `go/core/internal/httpserver/handlers/gitrepos.go` — proxies to gitrepo-mcp service +- **Controller config:** `GITREPO_MCP_URL` env var in controller configmap +- **UI:** `ui/src/app/git/page.tsx` — repo list, add, sync, search +- **Dockerfile:** Custom Dockerfile with `chainguard/wolfi-base` for `git` binary +- **Helm:** Sub-chart in `helm/tools/gitrepo-mcp/` + +### Test Plan + +- API endpoint tests for CRUD and search +- E2E tests for clone and sync operations diff --git a/design/EP-2007-temporal-workflows-ui.md b/design/EP-2007-temporal-workflows-ui.md new file mode 100644 index 000000000..00999498f --- /dev/null +++ b/design/EP-2007-temporal-workflows-ui.md @@ -0,0 +1,38 @@ +# EP-2007: Temporal Workflows Admin UI Plugin + +* Status: **Implemented** +* Spec: [specs/temporal-workflows-ui](../specs/temporal-workflows-ui/) + +## Background + +Custom Temporal workflow administration plugin (`temporal-mcp`) following the kanban-mcp architecture pattern. Stateless Go binary providing MCP tools, REST API, and embedded SPA with SSE live updates for monitoring agent workflow executions. + +## Motivation + +The stock Temporal UI (SvelteKit) doesn't integrate with kagent's plugin system and can't be proxied through iframes due to CSRF protection and relative module paths. A lightweight custom UI provides workflow visibility within the kagent shell. + +### Goals + +- 4 MCP tools: list, get, cancel, signal workflows +- REST API for workflow queries +- Embedded vanilla JS SPA with SSE polling (5s interval) +- Stateless — connects directly to Temporal Server via gRPC +- Registered as RemoteMCPServer CRD under Workflows sidebar entry + +### Non-Goals + +- Full Temporal UI feature parity +- Workflow definition editing +- Temporal namespace management + +## Implementation Details + +- **Binary:** `go/plugins/temporal-mcp/` — Go server with MCP + REST + SSE + embedded SPA +- **Config:** `TEMPORAL_HOST_PORT`, `TEMPORAL_NAMESPACE`, `TEMPORAL_ADDR` env vars +- **Helm:** `temporal-ui-deployment.yaml` uses temporal-mcp image (not stock `temporalio/ui`) +- **Build:** `make build-temporal-mcp` target in Makefile + +### Test Plan + +- Unit tests per package +- Integration test with Temporal Server diff --git a/design/EP-2008-mcp-servers-search.md b/design/EP-2008-mcp-servers-search.md new file mode 100644 index 000000000..6e0f05f86 --- /dev/null +++ b/design/EP-2008-mcp-servers-search.md @@ -0,0 +1,36 @@ +# EP-2008: MCP Servers Page Search and Layout + +* Status: **Implemented** +* Spec: [specs/mcp-servers-search-layout](../specs/mcp-servers-search-layout/) + +## Background + +Single-file enhancement to the MCP Servers page adding client-side search, auto-expand on match, and improved layout. + +## Motivation + +With many MCP servers and tools, users need to quickly find specific tools by name or description without manually expanding each server. + +### Goals + +- Search bar filtering by server name, tool name, and tool description +- Auto-expand servers when search matches their tools +- Search term highlighting in results +- ScrollArea for viewport-filling layout +- All servers expanded by default + +### Non-Goals + +- Server-side search +- Tool execution from search results + +## Implementation Details + +- **File:** `ui/src/app/servers/page.tsx` — single file modification +- **Search:** Client-side `useMemo` filter with `useState` for search term +- **Layout:** `ScrollArea` component for scroll containment + +### Test Plan + +- Manual testing with multiple MCP servers +- Search match highlighting verification diff --git a/design/EP-2009-dashboard-page.md b/design/EP-2009-dashboard-page.md new file mode 100644 index 000000000..deb7c821a --- /dev/null +++ b/design/EP-2009-dashboard-page.md @@ -0,0 +1,38 @@ +# EP-2009: Dashboard Overview Page + +* Status: **Implemented** +* Spec: [specs/dashboard-page](../specs/dashboard-page/) + +## Background + +New Dashboard page at `/` replacing the AgentList as the application homepage. Shows aggregated resource counts, recent activity, and system status at a glance. + +## Motivation + +Users need a quick overview of their kagent deployment — how many agents, tools, workflows exist, and what recent activity has occurred — without navigating to individual pages. + +### Goals + +- 7 resource stat cards: Agents, Workflows, CronJobs, Models, Tools, MCPServers, GitRepos +- Recent runs panel from DB sessions +- Recent events feed from session events +- Activity chart placeholder (mock data, Prometheus/Temporal integration later) +- Backend stats endpoint: `GET /api/dashboard/stats` + +### Non-Goals + +- Real-time streaming dashboard (pseudo-feed from recent events only) +- Clickable stat cards with drill-down +- Prometheus metrics integration (future) + +## Implementation Details + +- **Backend:** `go/core/internal/httpserver/handlers/dashboard.go` — aggregated DB COUNT queries +- **API types:** `DashboardStatsResponse` in `go/api/httpapi/types.go` +- **UI components:** `StatCard`, `StatsRow`, `RecentRunsPanel`, `LiveFeedPanel`, `ActivityChart`, `DashboardTopBar` +- **Page:** `ui/src/app/page.tsx` + +### Test Plan + +- Backend unit tests for stats aggregation +- UI component rendering tests diff --git a/design/EP-2010-pluggable-ui-app-proxy.md b/design/EP-2010-pluggable-ui-app-proxy.md new file mode 100644 index 000000000..eafcaa92a --- /dev/null +++ b/design/EP-2010-pluggable-ui-app-proxy.md @@ -0,0 +1,25 @@ +# EP-2010: Pluggable UI App Proxy + +* Status: **Superseded** by [EP-2004](EP-2004-dynamic-mcp-ui-routing.md) +* Spec: [specs/pluggable-ui-app-proxy](../specs/pluggable-ui-app-proxy/) + +## Background + +Early concept for making plugin UIs accessible via `/plugins//` URLs through an API proxy. This rough idea was superseded by the more comprehensive dynamic MCP UI routing system (EP-2004). + +## Motivation + +Plugin UIs needed to be accessible within the kagent shell without hardcoded nginx rules. + +### Goals + +- Extend existing API and UI proxy +- Make plugin UI accessible via `/plugins//` URLs + +### Non-Goals + +- This EP was superseded before detailed goals were defined + +## Implementation Details + +Superseded by EP-2004 which provides CRD-driven routing, iframe bridge, and plugin discovery. diff --git a/docker/skills-init/Dockerfile b/docker/skills-init/Dockerfile index ca0ff825c..ec166f6fd 100644 --- a/docker/skills-init/Dockerfile +++ b/docker/skills-init/Dockerfile @@ -1,18 +1,20 @@ ### Stage 0: build krane -FROM golang:1.25-alpine AS krane-builder +ARG BASE_IMAGE_REGISTRY=cgr.dev +FROM $BASE_IMAGE_REGISTRY/chainguard/go:latest AS krane-builder ENV KRANE_VERSION=v0.20.7 WORKDIR /build -RUN apk add --no-cache git && \ - git clone --depth 1 --branch $KRANE_VERSION \ +RUN git clone --depth 1 --branch $KRANE_VERSION \ https://github.com/google/go-containerregistry.git WORKDIR /build/go-containerregistry/cmd/krane RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/krane . -FROM alpine:3.21 +### Stage 1: final image +ARG BASE_IMAGE_REGISTRY=cgr.dev +FROM $BASE_IMAGE_REGISTRY/chainguard/wolfi-base:latest RUN apk add --no-cache git COPY --from=krane-builder /build/krane /usr/local/bin/krane diff --git a/docs/plugin-path-consistency.md b/docs/plugin-path-consistency.md new file mode 100644 index 000000000..73c9b635f --- /dev/null +++ b/docs/plugin-path-consistency.md @@ -0,0 +1,35 @@ +# Plugin path consistency: Go, Plugins (Helm/CRD), UI + +## Intended contract + +| Layer | Path | Purpose | +|-------|------|---------| +| **Browser URL** | `/plugins/{pathPrefix}` | Next.js page (sidebar + iframe). User-facing. | +| **Proxy (Go)** | `/_p/{pathPrefix}/` | Go backend reverse-proxies to plugin service. Used as iframe `src`. | +| **API** | `/api/plugins` | Returns list of plugins; each has `pathPrefix` used in the two paths above. | + +- **pathPrefix** comes from `RemoteMCPServer.spec.ui.pathPrefix` (or defaults to server name). Stored in DB; must be a single token (e.g. `kanban`, `temporal`). +- **UI** builds: sidebar link `href={/plugins/${p.pathPrefix}}`, iframe `src={/_p/${name}/}` where `name` is the route param (same as pathPrefix). + +## Inconsistencies found and fixed + +### 1. Temporal E2E test used wrong URL for proxy check (fixed) + +- **File:** `go/core/test/e2e/temporal_test.go` +- **Was:** `proxyURL := baseURL + "/plugins/temporal/"` — that hits nginx `location /` → Next.js, not the Go proxy. +- **Should be:** `proxyURL := baseURL + "/_p/temporal/"` — same as `plugin_routing_test.go`, which correctly uses `/_p/test-plugin/` to verify the proxy. + +### 2. CRD description incomplete (doc-only) + +- **File:** `go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml` (generated from Go types) +- **CRD says:** "When ui.enabled is true, the server's UI is accessible at /plugins/{ui.pathPrefix}/" +- **Go types say:** "accessible via /_p/{ui.pathPrefix}/ (proxy) and browser URL /plugins/{ui.pathPrefix}" +- The CRD does not mention the `/_p/` proxy path. For a single source of truth, the comment in `go/api/v1alpha2/remotemcpserver_types.go` is correct; the CRD description is generated and only mentions the browser URL. No code change required; keep Go types as the source. + +## Consistency checklist + +- [x] **Go:** Proxy route `/_p/{name}`, lookup by pathPrefix in DB. +- [x] **UI:** Sidebar links to `/plugins/${p.pathPrefix}`; iframe `src=/_p/${name}/` (name = pathPrefix from route). +- [x] **Helm (kanban-mcp):** `pathPrefix: "kanban"` → `/plugins/kanban`, `/_p/kanban/`. +- [x] **Helm (temporal):** `pathPrefix: "temporal"` → `/plugins/temporal`, `/_p/temporal/`. +- [x] **E2E:** Use `/_p/{pathPrefix}/` when asserting the Go proxy; use `/plugins/{pathPrefix}` only for browser/Next.js flows. diff --git a/examples/agentcronjob.yaml b/examples/agentcronjob.yaml new file mode 100644 index 000000000..b8e910c38 --- /dev/null +++ b/examples/agentcronjob.yaml @@ -0,0 +1,9 @@ +apiVersion: kagent.dev/v1alpha2 +kind: AgentCronJob +metadata: + name: daily-cluster-check + namespace: default +spec: + schedule: "0 9 * * *" + agentRef: "k8s-agent" + prompt: "Check the health of all pods in the cluster and report any issues." diff --git a/examples/workflows/agent-analysis.yaml b/examples/workflows/agent-analysis.yaml new file mode 100644 index 000000000..dd06fcabd --- /dev/null +++ b/examples/workflows/agent-analysis.yaml @@ -0,0 +1,109 @@ +# Example: Multi-Agent Analysis Workflow +# Demonstrates agent steps with parallel fan-out and aggregation. +# Three specialist agents analyze different aspects concurrently, +# then a synthesizer agent combines the results. +apiVersion: kagent.dev/v1alpha2 +kind: WorkflowTemplate +metadata: + name: agent-analysis + namespace: default +spec: + description: "Multi-agent analysis: gather data, analyze in parallel, synthesize" + params: + - name: topic + type: string + description: "Topic to analyze" + - name: depth + type: string + default: "standard" + enum: ["quick", "standard", "deep"] + description: "Analysis depth" + + defaults: + timeout: + startToClose: 15m + + retention: + successfulRunsHistoryLimit: 5 + failedRunsHistoryLimit: 3 + + steps: + - name: gather + type: action + action: data.gather + with: + topic: "${{ params.topic }}" + depth: "${{ params.depth }}" + output: + as: rawData + + - name: security-review + type: agent + agentRef: security-analyst + dependsOn: [gather] + prompt: | + Analyze the following data for security concerns. + Topic: ${{ params.topic }} + Data: ${{ context.rawData }} + Provide a JSON response with "findings" array and "riskLevel" (low/medium/high). + output: + keys: + findings: securityFindings + riskLevel: securityRisk + + - name: performance-review + type: agent + agentRef: performance-analyst + dependsOn: [gather] + prompt: | + Analyze the following data for performance characteristics. + Topic: ${{ params.topic }} + Data: ${{ context.rawData }} + Provide a JSON response with "metrics" and "recommendations" arrays. + onFailure: continue + output: + keys: + metrics: perfMetrics + recommendations: perfRecommendations + + - name: compliance-review + type: agent + agentRef: compliance-analyst + dependsOn: [gather] + prompt: | + Review the following data for compliance with standards. + Topic: ${{ params.topic }} + Data: ${{ context.rawData }} + Provide a JSON response with "status" (pass/fail) and "issues" array. + onFailure: continue + output: + keys: + status: complianceStatus + issues: complianceIssues + + - name: synthesize + type: agent + agentRef: report-synthesizer + dependsOn: [security-review, performance-review, compliance-review] + prompt: | + Synthesize the following analysis results into a comprehensive report. + Security: risk=${{ context.securityRisk }}, findings=${{ context.securityFindings }} + Performance: metrics=${{ context.perfMetrics }}, recommendations=${{ context.perfRecommendations }} + Compliance: status=${{ context.complianceStatus }}, issues=${{ context.complianceIssues }} + Provide a JSON response with "executiveSummary", "overallRisk", and "actionItems". + output: + as: finalReport +--- +apiVersion: kagent.dev/v1alpha2 +kind: WorkflowRun +metadata: + name: agent-analysis-run-001 + namespace: default +spec: + workflowTemplateRef: agent-analysis + params: + - name: topic + value: "kubernetes-cluster-health" + - name: depth + value: "deep" + ttlSecondsAfterFinished: 3600 diff --git a/examples/workflows/build-and-test.yaml b/examples/workflows/build-and-test.yaml new file mode 100644 index 000000000..8855b359d --- /dev/null +++ b/examples/workflows/build-and-test.yaml @@ -0,0 +1,117 @@ +# Example: CI Pipeline Workflow +# This workflow demonstrates a build-and-test pipeline with: +# - Sequential and parallel steps +# - Action steps (Temporal activities) and agent steps (child workflows) +# - Expression interpolation for parameter passing +# - Per-step retry and timeout policies +# - Retention policy for run history cleanup +apiVersion: kagent.dev/v1alpha2 +kind: WorkflowTemplate +metadata: + name: build-and-test + namespace: default +spec: + description: "CI pipeline: checkout, test, lint, analyze, build" + params: + - name: repoUrl + type: string + description: "Git repository URL" + - name: commitSha + type: string + description: "Commit SHA to build" + - name: runLint + type: boolean + default: "true" + description: "Whether to run linting" + + defaults: + retry: + maxAttempts: 3 + initialInterval: 1s + maximumInterval: 60s + backoffCoefficient: "2.0" + timeout: + startToClose: 10m + + retention: + successfulRunsHistoryLimit: 10 + failedRunsHistoryLimit: 5 + + steps: + - name: checkout + type: action + action: git.clone + with: + repoUrl: "${{ params.repoUrl }}" + commitSha: "${{ params.commitSha }}" + output: + as: checkout + + - name: unit-tests + type: action + action: ci.runTests + dependsOn: [checkout] + with: + workdir: "${{ context.checkout.path }}" + policy: + timeout: + startToClose: 15m + heartbeat: 30s + output: + keys: + report: testReport + + - name: lint + type: action + action: ci.runLint + dependsOn: [checkout] + with: + workdir: "${{ context.checkout.path }}" + onFailure: continue + output: + keys: + report: lintReport + + - name: analyze + type: agent + agentRef: code-quality-analyst + dependsOn: [unit-tests, lint] + prompt: | + Review these CI results and provide a quality assessment. + Test report: ${{ context.testReport }} + Lint report: ${{ context.lintReport }} + Return a JSON object with "summary" and "qualityGate" (PASS or FAIL). + output: + keys: + summary: analysisSummary + qualityGate: qualityGateStatus + + - name: build + type: action + action: ci.buildImage + dependsOn: [analyze] + with: + tag: "${{ params.commitSha }}" + qualityGate: "${{ context.qualityGateStatus }}" + policy: + retry: + maxAttempts: 2 + nonRetryableErrors: ["INVALID_DOCKERFILE"] + timeout: + startToClose: 20m + heartbeat: 60s +--- +# Example: Triggering a run of the build-and-test workflow +apiVersion: kagent.dev/v1alpha2 +kind: WorkflowRun +metadata: + name: build-and-test-run-001 + namespace: default +spec: + workflowTemplateRef: build-and-test + params: + - name: repoUrl + value: "https://github.com/kagent-dev/kagent" + - name: commitSha + value: "abc123def" + ttlSecondsAfterFinished: 86400 diff --git a/examples/workflows/data-pipeline.yaml b/examples/workflows/data-pipeline.yaml new file mode 100644 index 000000000..c31dad5ec --- /dev/null +++ b/examples/workflows/data-pipeline.yaml @@ -0,0 +1,76 @@ +# Example: Data Pipeline Workflow +# Simple ETL pipeline: extract data, transform it, then load results. +# Demonstrates linear step dependencies and output chaining. +apiVersion: kagent.dev/v1alpha2 +kind: WorkflowTemplate +metadata: + name: data-pipeline + namespace: default +spec: + description: "ETL pipeline: extract, transform, load" + params: + - name: source + type: string + description: "Data source identifier" + - name: destination + type: string + description: "Target destination" + - name: format + type: string + default: "json" + enum: ["json", "csv", "parquet"] + description: "Output format" + + defaults: + timeout: + startToClose: 30m + + steps: + - name: extract + type: action + action: etl.extract + with: + source: "${{ params.source }}" + format: "${{ params.format }}" + output: + as: extractResult + + - name: transform + type: action + action: etl.transform + dependsOn: [extract] + with: + data: "${{ context.extractResult.data }}" + format: "${{ params.format }}" + policy: + retry: + maxAttempts: 2 + output: + as: transformResult + + - name: load + type: action + action: etl.load + dependsOn: [transform] + with: + data: "${{ context.transformResult.data }}" + destination: "${{ params.destination }}" + policy: + timeout: + startToClose: 15m + heartbeat: 60s +--- +apiVersion: kagent.dev/v1alpha2 +kind: WorkflowRun +metadata: + name: data-pipeline-run-001 + namespace: default +spec: + workflowTemplateRef: data-pipeline + params: + - name: source + value: "s3://data-bucket/input" + - name: destination + value: "postgres://db/results" + - name: format + value: "json" diff --git a/go/Dockerfile b/go/Dockerfile index ecde46c7a..5f355ed4e 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -15,6 +15,11 @@ COPY go.work . COPY api/go.mod api/go.sum api/ COPY core/go.mod core/go.sum core/ COPY adk/go.mod adk/go.sum adk/ +COPY plugins/kanban-mcp/go.mod plugins/kanban-mcp/go.sum plugins/kanban-mcp/ +COPY plugins/gitrepo-mcp/go.mod plugins/gitrepo-mcp/go.sum plugins/gitrepo-mcp/ +COPY plugins/temporal-mcp/go.mod plugins/temporal-mcp/go.sum plugins/temporal-mcp/ +COPY plugins/nats-activity-feed/go.mod plugins/nats-activity-feed/go.sum plugins/nats-activity-feed/ +COPY plugins/cron-mcp/go.mod plugins/cron-mcp/go.sum plugins/cron-mcp/ # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ @@ -24,6 +29,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ COPY api/ api/ COPY core/ core/ COPY adk/ adk/ +COPY plugins/ plugins/ # Build ARG LDFLAGS diff --git a/go/Makefile b/go/Makefile index 43fcb7adc..e8668bf6d 100644 --- a/go/Makefile +++ b/go/Makefile @@ -114,6 +114,14 @@ test: ## Run all unit tests across the workspace. e2e: ## Run end-to-end tests. cd core && go test -v github.com/kagent-dev/kagent/go/core/test/e2e -failfast +.PHONY: e2e-temporal +e2e-temporal: ## Run Temporal E2E tests (requires Temporal + NATS in cluster). + cd core && TEMPORAL_ENABLED=1 go test -v -run 'TestE2ETemporal.*' github.com/kagent-dev/kagent/go/core/test/e2e -failfast + +.PHONY: e2e-cli +e2e-cli: ## Run CLI E2E tests (requires kagent cluster with deployed agents). + cd core && CLI_TEST=1 go test -v -run 'TestE2ECLI.*' github.com/kagent-dev/kagent/go/core/test/e2e -failfast -timeout=10m + ##@ Dependencies ## Location to install dependencies to diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index 61559db38..064dec9ef 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -8,18 +8,26 @@ import ( "strings" "time" + "encoding/json" + a2atype "github.com/a2aproject/a2a-go/a2a" "github.com/go-logr/logr" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + agentpkg "github.com/kagent-dev/kagent/go/adk/pkg/agent" "github.com/kagent-dev/kagent/go/adk/pkg/app" "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/config" + "github.com/kagent-dev/kagent/go/adk/pkg/mcp" runnerpkg "github.com/kagent-dev/kagent/go/adk/pkg/runner" "github.com/kagent-dev/kagent/go/adk/pkg/session" + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + "github.com/kagent-dev/kagent/go/adk/pkg/taskstore" + temporalpkg "github.com/kagent-dev/kagent/go/adk/pkg/temporal" "go.uber.org/zap" "go.uber.org/zap/zapcore" "google.golang.org/adk/server/adka2a" + "google.golang.org/genai" ) func setupLogger(logLevel string) (logr.Logger, *zap.Logger) { @@ -124,15 +132,7 @@ func main() { ctx := logr.NewContext(context.Background(), logger) - runnerConfig, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName) - if err != nil { - logger.Error(err, "Failed to create Google ADK Runner config") - os.Exit(1) - } - stream := agentConfig.GetStream() - execConfig := a2a.NewExecutorConfig(runnerConfig, sessionService, stream, appName, logger) - executor := a2a.WrapExecutorQueue(adka2a.NewExecutor(execConfig)) // Build the agent card. if agentCard == nil { @@ -147,27 +147,170 @@ func main() { StateTransitionHistory: true, } - // Delegate server, task store, and remaining infrastructure to app.New. - // Passing HTTPClient prevents app.New from creating a second token service. - kagentApp, err := app.New(app.AppConfig{ - AgentCard: *agentCard, - Host: *host, - Port: port, - KAgentURL: kagentURL, - AppName: appName, - ShutdownTimeout: 5 * time.Second, - Logger: logger, - HTTPClient: httpClient, - Agent: runnerConfig.Agent, - }, executor) - if err != nil { - logger.Error(err, "Failed to create app") - os.Exit(1) - } + // Determine executor: Temporal workflow or synchronous ADK. + temporalEnabled := agentConfig.Temporal != nil && agentConfig.Temporal.Enabled - if err := kagentApp.Run(); err != nil { - logger.Error(err, "Server error") - os.Exit(1) + if temporalEnabled { + temporalConfig := temporalpkg.FromRuntimeConfig(agentConfig.Temporal) + // Translator keeps infra endpoints in env vars; support those as + // fallback so temporal-enabled agents can boot when config omits them. + if temporalConfig.HostAddr == "" { + temporalConfig.HostAddr = os.Getenv("TEMPORAL_HOST_ADDR") + } + if temporalConfig.NATSAddr == "" { + temporalConfig.NATSAddr = os.Getenv("NATS_ADDR") + } + // Use Kubernetes namespace as Temporal namespace if not explicitly set. + if temporalConfig.Namespace == "" { + if ns := os.Getenv("KAGENT_NAMESPACE"); ns != "" { + temporalConfig.Namespace = ns + } + } + + // Use the Kubernetes agent name (KAGENT_NAME) for Temporal task queue + // and workflow IDs — not the __NS__-encoded appName. + kagentName := os.Getenv("KAGENT_NAME") + if kagentName == "" { + kagentName = appName // fallback for local development + } + + logger.Info("Temporal execution enabled", + "hostAddr", temporalConfig.HostAddr, + "namespace", temporalConfig.Namespace, + "taskQueue", temporalConfig.TaskQueue, + "agentName", kagentName, + "natsAddr", temporalConfig.NATSAddr) + + // Serialize agent config for workflow input. + configJSON, err := json.Marshal(agentConfig) + if err != nil { + logger.Error(err, "Failed to marshal agent config for Temporal") + os.Exit(1) + } + + // Connect to NATS. + natsConn, err := streaming.NewNATSConnection(temporalConfig.NATSAddr) + if err != nil { + logger.Error(err, "Failed to connect to NATS", "addr", temporalConfig.NATSAddr) + os.Exit(1) + } + logger.Info("Connected to NATS", "addr", temporalConfig.NATSAddr) + + // Create Temporal client. + temporalClient, err := temporalpkg.NewClient(temporalpkg.ClientConfig{ + TemporalAddr: temporalConfig.HostAddr, + Namespace: temporalConfig.Namespace, + }) + if err != nil { + natsConn.Close() + logger.Error(err, "Failed to create Temporal client", "addr", temporalConfig.HostAddr) + os.Exit(1) + } + logger.Info("Connected to Temporal", "addr", temporalConfig.HostAddr) + + // Terminate orphaned workflows from previous pod lifecycle. + // These workflows have no A2A executor waiting for their completion. + taskQueue := temporalConfig.TaskQueue + if taskQueue == "" { + taskQueue = temporalpkg.TaskQueueForAgent(kagentName) + } + if n, err := temporalClient.TerminateRunningWorkflows(ctx, taskQueue); err != nil { + logger.Error(err, "Failed to terminate orphaned workflows") + } else if n > 0 { + logger.Info("Terminated orphaned workflows from previous pod lifecycle", "count", n, "taskQueue", taskQueue) + } + + // Create task store for persisting A2A tasks via the KAgent controller API. + var taskStoreInstance *taskstore.KAgentTaskStore + if kagentURL != "" { + taskStoreInstance = taskstore.NewKAgentTaskStoreWithClient(kagentURL, httpClient) + logger.Info("Temporal activities using KAgent task store", "url", kagentURL) + } else { + logger.Info("No KAGENT_URL set, task persistence disabled for Temporal workflows") + } + + // Create MCP tool executor and discover tool declarations for the LLM. + var toolExecutor temporalpkg.ToolExecutor + var toolDecls []*genai.FunctionDeclaration + if len(agentConfig.HttpTools) > 0 || len(agentConfig.SseTools) > 0 { + result, err := mcp.CreateToolExecutor(ctx, agentConfig.HttpTools, agentConfig.SseTools) + if err != nil { + logger.Error(err, "Failed to create tool executor, tools will be unavailable") + } else if result != nil { + toolExecutor = result.Executor + toolDecls = result.ToolDeclarations + logger.Info("MCP tools ready", "executorTools", len(result.ToolDeclarations)) + } + } + + // Create activities and worker. + modelInvoker := agentpkg.NewModelInvoker(logger, toolDecls) + activities := temporalpkg.NewActivities(sessionService, taskStoreInstance, natsConn, modelInvoker, toolExecutor) + temporalWorker, err := temporalpkg.NewWorker(temporalClient.Temporal(), taskQueue, activities) + if err != nil { + temporalClient.Close() + natsConn.Close() + logger.Error(err, "Failed to create Temporal worker") + os.Exit(1) + } + + executor := a2a.NewTemporalExecutor(temporalClient, temporalConfig, natsConn, kagentName, appName, configJSON, logger) + + // Create app with temporal executor. + kagentApp, err := app.New(app.AppConfig{ + AgentCard: *agentCard, + Host: *host, + Port: port, + KAgentURL: kagentURL, + AppName: appName, + ShutdownTimeout: 5 * time.Second, + Logger: logger, + HTTPClient: httpClient, + }, a2a.WrapExecutorQueue(executor)) + if err != nil { + temporalClient.Close() + natsConn.Close() + logger.Error(err, "Failed to create app") + os.Exit(1) + } + + kagentApp.SetTemporalInfra(temporalClient, temporalWorker, natsConn) + + if err := kagentApp.Run(); err != nil { + logger.Error(err, "Server error") + os.Exit(1) + } + } else { + logger.Info("Temporal execution disabled, using synchronous execution") + runnerConfig, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName) + if err != nil { + logger.Error(err, "Failed to create Google ADK Runner config") + os.Exit(1) + } + + execConfig := a2a.NewExecutorConfig(runnerConfig, sessionService, stream, appName, logger) + executor := a2a.WrapExecutorQueue(adka2a.NewExecutor(execConfig)) + + kagentApp, err := app.New(app.AppConfig{ + AgentCard: *agentCard, + Host: *host, + Port: port, + KAgentURL: kagentURL, + AppName: appName, + ShutdownTimeout: 5 * time.Second, + Logger: logger, + HTTPClient: httpClient, + Agent: runnerConfig.Agent, + }, executor) + if err != nil { + logger.Error(err, "Failed to create app") + os.Exit(1) + } + + if err := kagentApp.Run(); err != nil { + logger.Error(err, "Server error") + os.Exit(1) + } } } diff --git a/go/adk/go.mod b/go/adk/go.mod index a99945a00..15597646c 100644 --- a/go/adk/go.mod +++ b/go/adk/go.mod @@ -11,13 +11,13 @@ require ( github.com/google/uuid v1.6.0 github.com/kagent-dev/kagent/go/api v0.0.0 github.com/kagent-dev/mockllm v0.0.4 - github.com/modelcontextprotocol/go-sdk v1.2.0 + github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/openai/openai-go/v3 v3.17.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/trace v1.40.0 go.uber.org/zap v1.27.0 - google.golang.org/adk v0.5.0 + google.golang.org/adk v0.6.0 google.golang.org/genai v1.40.0 ) @@ -26,6 +26,7 @@ require ( cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op // indirect github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect @@ -45,13 +46,21 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/jsonschema-go v0.3.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/safehtml v0.1.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect + github.com/nats-io/jwt/v2 v2.8.0 // indirect + github.com/nats-io/nats-server/v2 v2.12.4 // indirect + github.com/nats-io/nats.go v1.49.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect @@ -62,18 +71,21 @@ require ( go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.temporal.io/api v1.62.2 // indirect + go.temporal.io/sdk v1.40.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.252.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/omap v1.2.0 // indirect rsc.io/ordered v1.1.1 // indirect diff --git a/go/adk/go.sum b/go/adk/go.sum index e34c0737a..d673c69b7 100644 --- a/go/adk/go.sum +++ b/go/adk/go.sum @@ -10,6 +10,8 @@ github.com/a2aproject/a2a-go v0.3.6 h1:VbRoM2MNsfc7o4GkjGt3KZCjbqILAJq846K1z8rpH github.com/a2aproject/a2a-go v0.3.6/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= @@ -62,12 +64,17 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8= @@ -84,12 +91,30 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kagent-dev/mockllm v0.0.4 h1:wOy27YM705qsSB1jjpqRqTZbtZZiUPpoF/JrxXnB2aw= github.com/kagent-dev/mockllm v0.0.4/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= +github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.12.4 h1:ZnT10v2LU2Xcoiy8ek9X6Se4YG8EuMfIfvAEuFVx1Ts= +github.com/nats-io/nats-server/v2 v2.12.4/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/openai/openai-go/v3 v3.17.0 h1:CfTkmQoItolSyW+bHOUF190KuX5+1Zv6MC0Gb4wAwy8= github.com/openai/openai-go/v3 v3.17.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -129,6 +154,12 @@ go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4A go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.temporal.io/api v1.62.2 h1:jFhIzlqNyJsJZTiCRQmTIMv6OTQ5BZ57z8gbgLGMaoo= +go.temporal.io/api v1.62.2/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +go.temporal.io/sdk v1.40.0 h1:n9JN3ezVpWBxLzz5xViCo0sKxp7kVVhr1Su0bcMRNNs= +go.temporal.io/sdk v1.40.0/go.mod h1:tauxVfN174F0bdEs27+i0h8UPD7xBb6Py2SPHo7f1C0= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -137,26 +168,50 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/adk v0.5.0 h1:VFwJU8uX+S/wBZH6OatzyIrK6fd0oebVT9TnISb82FA= google.golang.org/adk v0.5.0/go.mod h1:W0RyHt+JXfZHA1VnxeGALRZeqAlp54nv2cw7Sn7M5Jc= +google.golang.org/adk v0.6.0 h1:hQl+K1qcvJ+B6rGBI+9T/Y6t21XsBQ8pRJqZYaOwK5M= +google.golang.org/adk v0.6.0/go.mod h1:nSTAyo0DQnua9dfuiDpMWq2crE9jE24ZaFJO4hwueUI= google.golang.org/api v0.252.0 h1:xfKJeAJaMwb8OC9fesr369rjciQ704AjU/psjkKURSI= google.golang.org/api v0.252.0/go.mod h1:dnHOv81x5RAmumZ7BWLShB/u7JZNeyalImxHmtTHxqw= google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= @@ -164,12 +219,19 @@ google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5g google.golang.org/genproto v0.0.0-20251014184007-4626949a642f h1:vLd1CJuJOUgV6qijD7KT5Y2ZtC97ll4dxjTUappMnbo= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/go/adk/pkg/a2a/temporal_executor.go b/go/adk/pkg/a2a/temporal_executor.go new file mode 100644 index 000000000..b6cd8ed92 --- /dev/null +++ b/go/adk/pkg/a2a/temporal_executor.go @@ -0,0 +1,296 @@ +package a2a + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + a2atype "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + "github.com/kagent-dev/kagent/go/adk/pkg/temporal" + "github.com/nats-io/nats.go" +) + +// TemporalExecutor implements a2asrv.AgentExecutor by starting Temporal +// workflows instead of running the agent synchronously. NATS streaming events +// are forwarded to the A2A event queue for SSE delivery. +type TemporalExecutor struct { + client *temporal.Client + config temporal.TemporalConfig + natsConn *nats.Conn + agentName string // K8s agent name for Temporal workflow/task queue naming + appName string // __NS__-encoded app name for session/DB lookups + configJSON []byte // serialized AgentConfig for workflow input + log logr.Logger +} + +var _ a2asrv.AgentExecutor = (*TemporalExecutor)(nil) + +// NewTemporalExecutor creates an executor that delegates to Temporal workflows. +// agentName is the Kubernetes agent name (e.g., "istio-agent") used for Temporal naming. +// appName is the encoded identifier (e.g., "kagent__NS__istio_agent") used for session/DB lookups. +func NewTemporalExecutor( + client *temporal.Client, + cfg temporal.TemporalConfig, + natsConn *nats.Conn, + agentName string, + appName string, + configJSON []byte, + logger logr.Logger, +) *TemporalExecutor { + return &TemporalExecutor{ + client: client, + config: cfg, + natsConn: natsConn, + agentName: agentName, + appName: appName, + configJSON: configJSON, + log: logger.WithName("temporal-executor"), + } +} + +// Execute starts a Temporal workflow for the given A2A request, subscribes to +// NATS for real-time streaming events, and forwards them to the A2A event queue. +func (e *TemporalExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { + if reqCtx.Message == nil { + return fmt.Errorf("A2A request message cannot be nil") + } + + sessionID := reqCtx.ContextID + userID := "A2A_USER_" + sessionID + + msgBytes, err := json.Marshal(reqCtx.Message) + if err != nil { + return fmt.Errorf("failed to marshal A2A message: %w", err) + } + + req := &temporal.ExecutionRequest{ + SessionID: sessionID, + UserID: userID, + AgentName: e.appName, + Message: msgBytes, + Config: e.configJSON, + NATSSubject: streaming.SubjectForAgent(e.agentName, sessionID), + } + + // Write submitted status. + submitted := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateSubmitted, nil) + if err := queue.Write(ctx, submitted); err != nil { + return fmt.Errorf("failed to write submitted status: %w", err) + } + + // Subscribe to NATS for streaming events AND completion tracking before starting workflow. + // Both must be set up before signaling to avoid race conditions. + completionCh := make(chan *temporal.ExecutionResult, 1) + if e.natsConn != nil { + var once sync.Once + sub, subErr := e.natsConn.Subscribe(req.NATSSubject, func(msg *nats.Msg) { + var event streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &event); err != nil { + return + } + if event.Type == streaming.EventTypeCompletion { + var result temporal.ExecutionResult + if err := json.Unmarshal([]byte(event.Data), &result); err == nil { + select { + case completionCh <- &result: + default: + } + } + return + } + e.forwardStreamEvent(ctx, reqCtx, queue, &event, &once) + }) + if subErr != nil { + e.log.Error(subErr, "Failed to subscribe to NATS, continuing without streaming", "subject", req.NATSSubject) + } else { + defer func() { _ = sub.Unsubscribe() }() + } + } + + // Write working status. + working := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil) + if err := queue.Write(ctx, working); err != nil { + return fmt.Errorf("failed to write working status: %w", err) + } + + // Signal-with-start: starts workflow if not running, or signals existing one. + run, err := e.client.ExecuteAgent(ctx, req, e.config) + if err != nil { + failMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: fmt.Sprintf("Failed to start workflow: %v", err)}) + failEvent := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, failMsg) + failEvent.Final = true + _ = queue.Write(ctx, failEvent) + return fmt.Errorf("failed to signal-with-start temporal workflow: %w", err) + } + + e.log.Info("Workflow signaled", "workflowID", run.GetID(), "runID", run.GetRunID(), "sessionID", sessionID) + + // Wait for the completion event via NATS. + // The workflow publishes a completion event after processing each message, + // so we don't need to wait for the entire session workflow to end. + select { + case result := <-completionCh: + return e.writeFinalStatus(ctx, reqCtx, queue, result) + case <-ctx.Done(): + failMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: "Request context cancelled"}) + failEvent := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, failMsg) + failEvent.Final = true + _ = queue.Write(ctx, failEvent) + return ctx.Err() + } +} + +// Cancel sends a cancellation for the workflow associated with the task. +func (e *TemporalExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { + cancelMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: "Task cancelled"}) + cancelEvent := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, cancelMsg) + cancelEvent.Final = true + return queue.Write(ctx, cancelEvent) +} + +// forwardStreamEvent converts a NATS streaming event to an A2A status update event. +// Tool events are formatted as DataParts with metadata matching the ADK convention +// (type: "function_call" / "function_response") so the UI renders tool call widgets. +func (e *TemporalExecutor) forwardStreamEvent( + ctx context.Context, + reqCtx *a2asrv.RequestContext, + queue eventqueue.Queue, + event *streaming.StreamEvent, + sentWorking *sync.Once, +) { + switch event.Type { + case streaming.EventTypeToken: + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: event.Data}) + // Mark as partial so the task store filters out individual token messages. + partialMeta := map[string]any{"adk_partial": true} + msg.Metadata = partialMeta + status := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + status.Metadata = partialMeta + if err := queue.Write(ctx, status); err != nil { + e.log.V(1).Info("Failed to forward token event", "error", err) + } + + case streaming.EventTypeToolStart: + // Parse structured tool call event and emit as function_call DataPart. + var callEvent streaming.ToolCallEvent + if err := json.Unmarshal([]byte(event.Data), &callEvent); err != nil { + e.log.V(1).Info("Failed to parse tool start event", "error", err) + return + } + var args map[string]any + if len(callEvent.Args) > 0 { + _ = json.Unmarshal(callEvent.Args, &args) + } + data := map[string]any{ + "id": callEvent.ID, + "name": callEvent.Name, + "args": args, + } + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.DataPart{ + Data: data, + Metadata: map[string]any{"adk_type": "function_call"}, + }) + status := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + if err := queue.Write(ctx, status); err != nil { + e.log.V(1).Info("Failed to forward tool start event", "error", err) + } + + case streaming.EventTypeToolEnd: + // Parse structured tool result event and emit as function_response DataPart. + var resultEvent streaming.ToolResultEvent + if err := json.Unmarshal([]byte(event.Data), &resultEvent); err != nil { + e.log.V(1).Info("Failed to parse tool end event", "error", err) + return + } + var response map[string]any + if len(resultEvent.Response) > 0 { + _ = json.Unmarshal(resultEvent.Response, &response) + } + data := map[string]any{ + "id": resultEvent.ID, + "name": resultEvent.Name, + "response": map[string]any{ + "isError": resultEvent.IsError, + "result": response, + }, + } + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.DataPart{ + Data: data, + Metadata: map[string]any{"adk_type": "function_response"}, + }) + status := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + if err := queue.Write(ctx, status); err != nil { + e.log.V(1).Info("Failed to forward tool end event", "error", err) + } + + case streaming.EventTypeApprovalRequest: + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: event.Data}) + status := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateInputRequired, msg) + if err := queue.Write(ctx, status); err != nil { + e.log.V(1).Info("Failed to forward approval request event", "error", err) + } + + case streaming.EventTypeError: + e.log.V(1).Info("Stream error event", "data", event.Data) + } +} + +// writeFinalStatus maps an ExecutionResult to the appropriate final A2A status event. +func (e *TemporalExecutor) writeFinalStatus( + ctx context.Context, + reqCtx *a2asrv.RequestContext, + queue eventqueue.Queue, + result *temporal.ExecutionResult, +) error { + var state a2atype.TaskState + var msg *a2atype.Message + + switch result.Status { + case "completed": + state = a2atype.TaskStateCompleted + text := "Task completed" + if len(result.Response) > 0 { + // Response is a serialized LLMResponse; extract the content field. + var llmResp struct { + Content string `json:"content"` + } + if err := json.Unmarshal(result.Response, &llmResp); err == nil && llmResp.Content != "" { + text = llmResp.Content + } else { + text = string(result.Response) + } + } + msg = a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: text}) + + case "rejected": + state = a2atype.TaskStateCanceled + reason := "Rejected" + if result.Reason != "" { + reason = "Rejected: " + result.Reason + } + msg = a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: reason}) + + case "failed": + state = a2atype.TaskStateFailed + reason := "Workflow failed" + if result.Reason != "" { + reason = result.Reason + } + msg = a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: reason}) + + default: + state = a2atype.TaskStateCompleted + msg = a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: "Task completed"}) + } + + finalEvent := a2atype.NewStatusUpdateEvent(reqCtx, state, msg) + finalEvent.Final = true + return queue.Write(ctx, finalEvent) +} diff --git a/go/adk/pkg/a2a/temporal_executor_test.go b/go/adk/pkg/a2a/temporal_executor_test.go new file mode 100644 index 000000000..20a7ccc65 --- /dev/null +++ b/go/adk/pkg/a2a/temporal_executor_test.go @@ -0,0 +1,445 @@ +package a2a + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + "github.com/kagent-dev/kagent/go/adk/pkg/temporal" + natsserver "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/mock" + temporalmocks "go.temporal.io/sdk/mocks" +) + +// testEventQueue captures A2A events written during executor tests. +type testEventQueue struct { + mu sync.Mutex + events []a2atype.Event +} + +func (q *testEventQueue) Write(_ context.Context, event a2atype.Event) error { + q.mu.Lock() + defer q.mu.Unlock() + q.events = append(q.events, event) + return nil +} + +func (q *testEventQueue) WriteVersioned(_ context.Context, event a2atype.Event, _ a2atype.TaskVersion) error { + return q.Write(context.Background(), event) +} + +func (q *testEventQueue) Read(_ context.Context) (a2atype.Event, a2atype.TaskVersion, error) { + return nil, 0, nil +} + +func (q *testEventQueue) Close() error { return nil } + +func (q *testEventQueue) getEvents() []a2atype.Event { + q.mu.Lock() + defer q.mu.Unlock() + out := make([]a2atype.Event, len(q.events)) + copy(out, q.events) + return out +} + +func newTestReqCtx() *a2asrv.RequestContext { + return &a2asrv.RequestContext{ + TaskID: "task-123", + ContextID: "session-456", + Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.TextPart{Text: "hello"}), + } +} + +func startEmbeddedNATS(t *testing.T) (*natsserver.Server, *nats.Conn) { + t.Helper() + opts := &natsserver.Options{ + Host: "127.0.0.1", + Port: -1, + } + ns, err := natsserver.NewServer(opts) + if err != nil { + t.Fatalf("Failed to create NATS server: %v", err) + } + ns.Start() + if !ns.ReadyForConnections(5 * time.Second) { + t.Fatal("NATS server not ready") + } + nc, err := nats.Connect(ns.ClientURL()) + if err != nil { + ns.Shutdown() + t.Fatalf("Failed to connect to NATS: %v", err) + } + t.Cleanup(func() { + nc.Close() + ns.Shutdown() + }) + return ns, nc +} + +// publishCompletion publishes a completion event to NATS after a short delay. +func publishCompletion(nc *nats.Conn, subject string, result *temporal.ExecutionResult, delay time.Duration) { + go func() { + time.Sleep(delay) + resultBytes, _ := json.Marshal(result) + event := streaming.NewStreamEvent(streaming.EventTypeCompletion, string(resultBytes)) + eventBytes, _ := json.Marshal(event) + _ = nc.Publish(subject, eventBytes) + }() +} + +func TestTemporalExecutor_NilMessage(t *testing.T) { + exec := NewTemporalExecutor(nil, temporal.TemporalConfig{}, nil, "test-agent", "test-agent", nil, logr.Discard()) + reqCtx := &a2asrv.RequestContext{TaskID: "t1", ContextID: "s1"} + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err == nil || err.Error() != "A2A request message cannot be nil" { + t.Errorf("Expected nil message error, got: %v", err) + } +} + +func TestTemporalExecutor_WorkflowCompleted(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockRun := &temporalmocks.WorkflowRun{} + + subject := streaming.SubjectForAgent("test-agent", "session-456") + + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockRun, nil) + mockRun.On("GetID").Return("wf-id") + mockRun.On("GetRunID").Return("run-id") + + // Simulate the workflow publishing a completion event via NATS. + completionResult := &temporal.ExecutionResult{ + SessionID: "session-456", + Status: "completed", + Response: []byte("Agent response text"), + } + publishCompletion(nc, subject, completionResult, 50*time.Millisecond) + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + events := queue.getEvents() + if len(events) < 3 { + t.Fatalf("Expected at least 3 events (submitted, working, final), got %d", len(events)) + } + + // First event: submitted + if se, ok := events[0].(*a2atype.TaskStatusUpdateEvent); ok { + if se.Status.State != a2atype.TaskStateSubmitted { + t.Errorf("Expected submitted state, got %v", se.Status.State) + } + } else { + t.Error("First event is not TaskStatusUpdateEvent") + } + + // Second: working + if se, ok := events[1].(*a2atype.TaskStatusUpdateEvent); ok { + if se.Status.State != a2atype.TaskStateWorking { + t.Errorf("Expected working state, got %v", se.Status.State) + } + } + + // Last: completed final + last := events[len(events)-1] + if se, ok := last.(*a2atype.TaskStatusUpdateEvent); ok { + if se.Status.State != a2atype.TaskStateCompleted { + t.Errorf("Expected completed state, got %v", se.Status.State) + } + if !se.Final { + t.Error("Expected final event") + } + } else { + t.Error("Last event is not TaskStatusUpdateEvent") + } + + mockClient.AssertExpectations(t) + mockRun.AssertExpectations(t) +} + +func TestTemporalExecutor_WorkflowFailed(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockRun := &temporalmocks.WorkflowRun{} + + subject := streaming.SubjectForAgent("test-agent", "session-456") + + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockRun, nil) + mockRun.On("GetID").Return("wf-id") + mockRun.On("GetRunID").Return("run-id") + + completionResult := &temporal.ExecutionResult{ + SessionID: "session-456", + Status: "failed", + Reason: "LLM timeout", + } + publishCompletion(nc, subject, completionResult, 50*time.Millisecond) + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + events := queue.getEvents() + last := events[len(events)-1] + if se, ok := last.(*a2atype.TaskStatusUpdateEvent); ok { + if se.Status.State != a2atype.TaskStateFailed { + t.Errorf("Expected failed state, got %v", se.Status.State) + } + if !se.Final { + t.Error("Expected final event") + } + } +} + +func TestTemporalExecutor_WorkflowRejected(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockRun := &temporalmocks.WorkflowRun{} + + subject := streaming.SubjectForAgent("test-agent", "session-456") + + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockRun, nil) + mockRun.On("GetID").Return("wf-id") + mockRun.On("GetRunID").Return("run-id") + + completionResult := &temporal.ExecutionResult{ + SessionID: "session-456", + Status: "rejected", + Reason: "User declined", + } + publishCompletion(nc, subject, completionResult, 50*time.Millisecond) + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + events := queue.getEvents() + last := events[len(events)-1] + if se, ok := last.(*a2atype.TaskStatusUpdateEvent); ok { + if se.Status.State != a2atype.TaskStateCanceled { + t.Errorf("Expected canceled state for rejection, got %v", se.Status.State) + } + } +} + +func TestTemporalExecutor_StartWorkflowError(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("connection refused")) + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err == nil { + t.Fatal("Expected error when workflow start fails") + } + + events := queue.getEvents() + foundFailed := false + for _, ev := range events { + if se, ok := ev.(*a2atype.TaskStatusUpdateEvent); ok && se.Status.State == a2atype.TaskStateFailed { + foundFailed = true + } + } + if !foundFailed { + t.Error("Expected a failed status event") + } +} + +func TestTemporalExecutor_ContextCancelled(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockRun := &temporalmocks.WorkflowRun{} + + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockRun, nil) + mockRun.On("GetID").Return("wf-id") + mockRun.On("GetRunID").Return("run-id") + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel after a short delay to simulate timeout + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(ctx, reqCtx, queue) + if err == nil { + t.Fatal("Expected error when context is cancelled") + } +} + +func TestTemporalExecutor_NATSStreaming(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockRun := &temporalmocks.WorkflowRun{} + + subject := streaming.SubjectForAgent("test-agent", "session-456") + + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockRun, nil) + mockRun.On("GetID").Return("wf-id") + mockRun.On("GetRunID").Return("run-id") + + // Publish streaming events then completion after a short delay. + go func() { + time.Sleep(30 * time.Millisecond) + pub := streaming.NewStreamPublisher(nc) + tokenEvent := streaming.NewStreamEvent(streaming.EventTypeToken, "Hello") + _ = pub.PublishToken(subject, tokenEvent) + toolStart := streaming.NewStreamEvent(streaming.EventTypeToolStart, "search") + _ = pub.PublishToolProgress(subject, toolStart) + toolEnd := streaming.NewStreamEvent(streaming.EventTypeToolEnd, "search") + _ = pub.PublishToolProgress(subject, toolEnd) + time.Sleep(30 * time.Millisecond) + + // Publish completion + completionResult := &temporal.ExecutionResult{ + SessionID: "session-456", + Status: "completed", + Response: []byte("done"), + } + resultBytes, _ := json.Marshal(completionResult) + completionEvent := streaming.NewStreamEvent(streaming.EventTypeCompletion, string(resultBytes)) + completionBytes, _ := json.Marshal(completionEvent) + _ = nc.Publish(subject, completionBytes) + }() + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + events := queue.getEvents() + if len(events) < 4 { + t.Errorf("Expected at least 4 events with streaming, got %d", len(events)) + } + + // Verify we got streaming events (working state with content) + workingCount := 0 + for _, ev := range events { + if se, ok := ev.(*a2atype.TaskStatusUpdateEvent); ok && se.Status.State == a2atype.TaskStateWorking && se.Status.Message != nil { + workingCount++ + } + } + if workingCount < 1 { + t.Error("Expected at least 1 streaming working event from NATS") + } +} + +func TestTemporalExecutor_Cancel(t *testing.T) { + exec := NewTemporalExecutor(nil, temporal.TemporalConfig{}, nil, "test-agent", "test-agent", nil, logr.Discard()) + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Cancel(context.Background(), reqCtx, queue) + if err != nil { + t.Fatalf("Cancel returned error: %v", err) + } + + events := queue.getEvents() + if len(events) != 1 { + t.Fatalf("Expected 1 cancel event, got %d", len(events)) + } + if se, ok := events[0].(*a2atype.TaskStatusUpdateEvent); ok { + if se.Status.State != a2atype.TaskStateCanceled { + t.Errorf("Expected canceled state, got %v", se.Status.State) + } + if !se.Final { + t.Error("Expected final event") + } + } +} + +func TestTemporalExecutor_ForwardApprovalRequest(t *testing.T) { + _, nc := startEmbeddedNATS(t) + + mockClient := &temporalmocks.Client{} + mockRun := &temporalmocks.WorkflowRun{} + + subject := streaming.SubjectForAgent("test-agent", "session-456") + + mockClient.On("SignalWithStartWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mockRun, nil) + mockRun.On("GetID").Return("wf-id") + mockRun.On("GetRunID").Return("run-id") + + // Publish approval event then completion. + go func() { + time.Sleep(30 * time.Millisecond) + pub := streaming.NewStreamPublisher(nc) + approvalData, _ := json.Marshal(map[string]string{"tool": "dangerous_tool"}) + approvalEvent := streaming.NewStreamEvent(streaming.EventTypeApprovalRequest, string(approvalData)) + _ = pub.PublishToken(subject, approvalEvent) + time.Sleep(30 * time.Millisecond) + + completionResult := &temporal.ExecutionResult{Status: "completed"} + resultBytes, _ := json.Marshal(completionResult) + completionEvent := streaming.NewStreamEvent(streaming.EventTypeCompletion, string(resultBytes)) + completionBytes, _ := json.Marshal(completionEvent) + _ = nc.Publish(subject, completionBytes) + }() + + temporalClient := temporal.NewClientFromExisting(mockClient) + exec := NewTemporalExecutor(temporalClient, temporal.DefaultTemporalConfig(), nc, "test-agent", "test-agent", []byte(`{}`), logr.Discard()) + + reqCtx := newTestReqCtx() + queue := &testEventQueue{} + err := exec.Execute(context.Background(), reqCtx, queue) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + events := queue.getEvents() + foundApproval := false + for _, ev := range events { + if se, ok := ev.(*a2atype.TaskStatusUpdateEvent); ok && se.Status.State == a2atype.TaskStateInputRequired { + foundApproval = true + } + } + if !foundApproval { + t.Error("Expected an input_required event for approval request") + } +} diff --git a/go/adk/pkg/agent/modelinvoker.go b/go/adk/pkg/agent/modelinvoker.go new file mode 100644 index 000000000..f6f309c57 --- /dev/null +++ b/go/adk/pkg/agent/modelinvoker.go @@ -0,0 +1,192 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/adk/pkg/temporal" + "github.com/kagent-dev/kagent/go/api/adk" + adkmodel "google.golang.org/adk/model" + "google.golang.org/genai" +) + +// NewModelInvoker returns a temporal.ModelInvoker that creates an LLM from +// the serialized AgentConfig, converts the conversation history to genai +// format, and invokes the model. +// +// toolDecls are the MCP tool declarations discovered at startup. They are +// passed to the LLM so it knows which tools are available and can generate +// FunctionCall responses. If nil, the LLM will not produce tool calls. +func NewModelInvoker(logger logr.Logger, toolDecls []*genai.FunctionDeclaration) temporal.ModelInvoker { + return func(ctx context.Context, configBytes []byte, historyBytes []byte, onToken func(string)) (*temporal.LLMResponse, error) { + log := logger.WithName("model-invoker") + + // 1. Parse agent config. + var agentConfig adk.AgentConfig + if err := json.Unmarshal(configBytes, &agentConfig); err != nil { + return nil, fmt.Errorf("failed to parse agent config: %w", err) + } + + if agentConfig.Model == nil { + return nil, fmt.Errorf("agent config has no model configuration") + } + + // 2. Create LLM from config. + llm, err := createLLM(ctx, agentConfig.Model, log) + if err != nil { + return nil, fmt.Errorf("failed to create LLM: %w", err) + } + + // 3. Parse conversation history. + var history []conversationEntry + if err := json.Unmarshal(historyBytes, &history); err != nil { + return nil, fmt.Errorf("failed to parse conversation history: %w", err) + } + + // 4. Convert history to genai.Content format. + contents := historyToContents(history) + + // 5. Build LLM request with system instruction and tool declarations. + genConfig := &genai.GenerateContentConfig{} + if agentConfig.Instruction != "" { + genConfig.SystemInstruction = &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + genai.NewPartFromText(agentConfig.Instruction), + }, + } + } + + // Include tool declarations so the LLM can generate FunctionCall responses. + if len(toolDecls) > 0 { + genConfig.Tools = []*genai.Tool{{ + FunctionDeclarations: toolDecls, + }} + } + + req := &adkmodel.LLMRequest{ + Contents: contents, + Config: genConfig, + } + + // 6. Invoke LLM (non-streaming; collect full response). + stream := onToken != nil + var finalResp *adkmodel.LLMResponse + for resp, err := range llm.GenerateContent(ctx, req, stream) { + if err != nil { + return nil, fmt.Errorf("LLM generation failed: %w", err) + } + if resp.Partial && onToken != nil { + // Stream partial text tokens. + if resp.Content != nil { + for _, part := range resp.Content.Parts { + if part.Text != "" { + onToken(part.Text) + } + } + } + continue + } + finalResp = resp + } + + if finalResp == nil { + return nil, fmt.Errorf("LLM returned no response") + } + + // 7. Convert LLM response to temporal.LLMResponse. + return convertResponse(finalResp) + } +} + +// conversationEntry mirrors the workflow's conversation history format. +type conversationEntry struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []temporal.ToolCall `json:"toolCalls,omitempty"` + ToolCallID string `json:"toolCallID,omitempty"` + ToolResult json.RawMessage `json:"toolResult,omitempty"` +} + +// historyToContents converts conversation entries to genai.Content slices. +func historyToContents(history []conversationEntry) []*genai.Content { + var contents []*genai.Content + + for _, entry := range history { + switch entry.Role { + case "user": + contents = append(contents, &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + genai.NewPartFromText(entry.Content), + }, + }) + + case "assistant": + c := &genai.Content{Role: "model"} + if entry.Content != "" { + c.Parts = append(c.Parts, genai.NewPartFromText(entry.Content)) + } + for _, tc := range entry.ToolCalls { + var args map[string]any + if len(tc.Args) > 0 { + _ = json.Unmarshal(tc.Args, &args) + } + c.Parts = append(c.Parts, genai.NewPartFromFunctionCall(tc.Name, args)) + } + if len(c.Parts) > 0 { + contents = append(contents, c) + } + + case "tool": + var result map[string]any + if len(entry.ToolResult) > 0 { + _ = json.Unmarshal(entry.ToolResult, &result) + } + if result == nil { + result = map[string]any{"result": string(entry.ToolResult)} + } + contents = append(contents, &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + genai.NewPartFromFunctionResponse(entry.ToolCallID, result), + }, + }) + } + } + + return contents +} + +// convertResponse converts a Google ADK LLM response to a temporal.LLMResponse. +func convertResponse(resp *adkmodel.LLMResponse) (*temporal.LLMResponse, error) { + result := &temporal.LLMResponse{} + + if resp.Content == nil { + result.Terminal = true + return result, nil + } + + for _, part := range resp.Content.Parts { + if part.Text != "" { + result.Content += part.Text + } + if part.FunctionCall != nil { + argsBytes, _ := json.Marshal(part.FunctionCall.Args) + result.ToolCalls = append(result.ToolCalls, temporal.ToolCall{ + ID: part.FunctionCall.ID, + Name: part.FunctionCall.Name, + Args: argsBytes, + }) + } + } + + // Terminal if no tool calls and no agent calls. + if len(result.ToolCalls) == 0 && len(result.AgentCalls) == 0 { + result.Terminal = true + } + + return result, nil +} diff --git a/go/adk/pkg/app/app.go b/go/adk/pkg/app/app.go index 2b5286cab..8a1896dff 100644 --- a/go/adk/pkg/app/app.go +++ b/go/adk/pkg/app/app.go @@ -16,7 +16,10 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/a2a/server" "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/session" + temporalpkg "github.com/kagent-dev/kagent/go/adk/pkg/temporal" "github.com/kagent-dev/kagent/go/adk/pkg/taskstore" + "github.com/nats-io/nats.go" + "go.temporal.io/sdk/worker" "go.uber.org/zap" "go.uber.org/zap/zapcore" adkagent "google.golang.org/adk/agent" @@ -77,6 +80,11 @@ type KAgentApp struct { tokenService *auth.KAgentTokenService sessionService session.SessionService logger logr.Logger + + // Temporal infrastructure (nil when temporal is not enabled). + temporalClient *temporalpkg.Client + temporalWorker worker.Worker + natsConn *nats.Conn } // New creates a KAgentApp by wiring the provided executor with kagent @@ -144,9 +152,26 @@ func New(cfg AppConfig, executor a2asrv.AgentExecutor) (*KAgentApp, error) { return app, nil } -// Run starts the A2A server and blocks until a shutdown signal is received. +// SetTemporalInfra sets Temporal infrastructure components for lifecycle management. +// The app will start the worker alongside the A2A server and shut everything down gracefully. +func (a *KAgentApp) SetTemporalInfra(client *temporalpkg.Client, w worker.Worker, natsConn *nats.Conn) { + a.temporalClient = client + a.temporalWorker = w + a.natsConn = natsConn +} + +// Run starts the A2A server (and Temporal worker if configured) and blocks +// until a shutdown signal is received. func (a *KAgentApp) Run() error { defer a.stop() + + if a.temporalWorker != nil { + a.logger.Info("Starting Temporal worker") + if err := a.temporalWorker.Start(); err != nil { + return fmt.Errorf("failed to start temporal worker: %w", err) + } + } + return a.server.Run() } @@ -161,8 +186,20 @@ func (a *KAgentApp) Logger() logr.Logger { return a.logger } -// stop cleans up resources. +// stop cleans up resources in the correct order: worker -> NATS -> Temporal client -> token service. func (a *KAgentApp) stop() { + if a.temporalWorker != nil { + a.logger.Info("Stopping Temporal worker") + a.temporalWorker.Stop() + } + if a.natsConn != nil { + a.logger.Info("Closing NATS connection") + a.natsConn.Close() + } + if a.temporalClient != nil { + a.logger.Info("Closing Temporal client") + a.temporalClient.Close() + } if a.tokenService != nil { a.tokenService.Stop() } diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 6833b48a7..488a51ab7 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -4,9 +4,11 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "net/http" "os" + "strings" "time" "github.com/go-logr/logr" @@ -14,6 +16,7 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "google.golang.org/adk/tool" "google.golang.org/adk/tool/mcptoolset" + "google.golang.org/genai" ) const ( @@ -182,8 +185,9 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp } } else { mcpTransport = &mcpsdk.StreamableClientTransport{ - Endpoint: params.URL, - HTTPClient: httpClient, + Endpoint: params.URL, + HTTPClient: httpClient, + DisableStandaloneSSE: true, } } @@ -204,6 +208,227 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro return rt.base.RoundTrip(req) } +// mcpSession wraps an MCP client session for direct tool calls. +type mcpSession struct { + client *mcpsdk.Client + transport mcpsdk.Transport + session *mcpsdk.ClientSession +} + +func newMCPSession(transport mcpsdk.Transport) *mcpSession { + return &mcpSession{ + client: mcpsdk.NewClient(&mcpsdk.Implementation{Name: "kagent-temporal", Version: "0.1.0"}, nil), + transport: transport, + } +} + +func (s *mcpSession) connect(ctx context.Context) error { + session, err := s.client.Connect(ctx, s.transport, nil) + if err != nil { + return fmt.Errorf("failed to connect MCP session: %w", err) + } + s.session = session + return nil +} + +func (s *mcpSession) listTools(ctx context.Context) ([]*mcpsdk.Tool, error) { + var tools []*mcpsdk.Tool + cursor := "" + for { + resp, err := s.session.ListTools(ctx, &mcpsdk.ListToolsParams{Cursor: cursor}) + if err != nil { + return nil, err + } + tools = append(tools, resp.Tools...) + if resp.NextCursor == "" { + break + } + cursor = resp.NextCursor + } + return tools, nil +} + +func (s *mcpSession) callTool(ctx context.Context, name string, args any) (*mcpsdk.CallToolResult, error) { + return s.session.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: name, + Arguments: args, + }) +} + +// mcpToolRouter maps tool names to their MCP client connections for direct +// tool execution outside the ADK agent pipeline (e.g., Temporal activities). +type mcpToolRouter struct { + // toolSessions maps tool name -> MCP session that serves it. + toolSessions map[string]*mcpSession +} + +// ToolExecutorResult holds both the executor function and the discovered tool +// declarations for the LLM. This avoids connecting to MCP servers twice. +type ToolExecutorResult struct { + // Executor routes tool calls to the correct MCP server. + Executor func(ctx context.Context, toolName string, args []byte) ([]byte, error) + // ToolDeclarations are genai.FunctionDeclaration entries for the LLM. + ToolDeclarations []*genai.FunctionDeclaration +} + +// CreateToolExecutor creates a ToolExecutor function that routes tool calls to +// the correct MCP server. It connects to all configured MCP servers, discovers +// their tools, builds a routing table, and also returns tool declarations for the LLM. +func CreateToolExecutor(ctx context.Context, httpTools []adk.HttpMcpServerConfig, sseTools []adk.SseMcpServerConfig) (*ToolExecutorResult, error) { + log := logr.FromContextOrDiscard(ctx) + + router := &mcpToolRouter{ + toolSessions: make(map[string]*mcpSession), + } + + var decls []*genai.FunctionDeclaration + + // Connect to HTTP MCP servers and discover tools. + for i, httpTool := range httpTools { + params := mcpServerParams{ + URL: httpTool.Params.Url, + Headers: httpTool.Params.Headers, + ServerType: "http", + Timeout: httpTool.Params.Timeout, + SseReadTimeout: httpTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: httpTool.Params.TLSInsecureSkipVerify, + TLSCACertPath: httpTool.Params.TLSCACertPath, + TLSDisableSystemCAs: httpTool.Params.TLSDisableSystemCAs, + } + serverDecls, err := router.addServer(ctx, log, params, httpTool.Tools, "HTTP", i+1) + if err != nil { + log.Error(err, "Failed to add HTTP MCP server for tool executor", "url", params.URL) + // Continue — partial tool support is better than none. + } + decls = append(decls, serverDecls...) + } + + // Connect to SSE MCP servers and discover tools. + for i, sseTool := range sseTools { + params := mcpServerParams{ + URL: sseTool.Params.Url, + Headers: sseTool.Params.Headers, + ServerType: "sse", + Timeout: sseTool.Params.Timeout, + SseReadTimeout: sseTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: sseTool.Params.TLSInsecureSkipVerify, + TLSCACertPath: sseTool.Params.TLSCACertPath, + TLSDisableSystemCAs: sseTool.Params.TLSDisableSystemCAs, + } + serverDecls, err := router.addServer(ctx, log, params, sseTool.Tools, "SSE", i+1) + if err != nil { + log.Error(err, "Failed to add SSE MCP server for tool executor", "url", params.URL) + } + decls = append(decls, serverDecls...) + } + + if len(router.toolSessions) == 0 { + log.Info("No MCP tools discovered for tool executor") + return &ToolExecutorResult{}, nil + } + + toolNames := make([]string, 0, len(router.toolSessions)) + for name := range router.toolSessions { + toolNames = append(toolNames, name) + } + log.Info("Tool executor ready", "toolCount", len(router.toolSessions), "tools", toolNames) + + return &ToolExecutorResult{ + Executor: router.execute, + ToolDeclarations: decls, + }, nil +} + +// addServer connects to an MCP server, discovers its tools, registers them, +// and returns genai.FunctionDeclaration entries for the discovered tools. +func (r *mcpToolRouter) addServer(ctx context.Context, log logr.Logger, params mcpServerParams, toolFilter []string, label string, index int) ([]*genai.FunctionDeclaration, error) { + transport, err := createTransport(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create transport for %s server %d (%s): %w", label, index, params.URL, err) + } + + sess := newMCPSession(transport) + if err := sess.connect(ctx); err != nil { + return nil, fmt.Errorf("failed to connect to %s server %d (%s): %w", label, index, params.URL, err) + } + + // Discover tools from this server. + tools, err := sess.listTools(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list tools from %s server %d (%s): %w", label, index, params.URL, err) + } + + // Build filter set if configured. + filterSet := make(map[string]bool, len(toolFilter)) + for _, name := range toolFilter { + filterSet[name] = true + } + + var decls []*genai.FunctionDeclaration + registered := 0 + for _, t := range tools { + if len(filterSet) > 0 && !filterSet[t.Name] { + continue + } + r.toolSessions[t.Name] = sess + decls = append(decls, &genai.FunctionDeclaration{ + Name: t.Name, + Description: t.Description, + ParametersJsonSchema: t.InputSchema, + }) + registered++ + } + + log.Info(fmt.Sprintf("Registered %s MCP tools for executor", label), + "index", index, "url", params.URL, "registered", registered, "total", len(tools)) + return decls, nil +} + +// execute implements the ToolExecutor signature. +func (r *mcpToolRouter) execute(ctx context.Context, toolName string, args []byte) ([]byte, error) { + sess, ok := r.toolSessions[toolName] + if !ok { + return nil, fmt.Errorf("unknown tool %q: not registered with any MCP server", toolName) + } + + // Parse args from JSON to map for CallTool. + var arguments any + if len(args) > 0 { + if err := json.Unmarshal(args, &arguments); err != nil { + return nil, fmt.Errorf("failed to unmarshal tool args for %q: %w", toolName, err) + } + } + + result, err := sess.callTool(ctx, toolName, arguments) + if err != nil { + return nil, fmt.Errorf("MCP tool %q execution failed: %w", toolName, err) + } + + if result.IsError { + var details strings.Builder + for _, c := range result.Content { + if tc, ok := c.(*mcpsdk.TextContent); ok { + details.WriteString(tc.Text) + } + } + return nil, fmt.Errorf("MCP tool %q returned error: %s", toolName, details.String()) + } + + // Build text result from content parts. + if result.StructuredContent != nil { + return json.Marshal(result.StructuredContent) + } + + var text strings.Builder + for _, c := range result.Content { + if tc, ok := c.(*mcpsdk.TextContent); ok { + text.WriteString(tc.Text) + } + } + + return json.Marshal(map[string]string{"output": text.String()}) +} + // initializeToolSet fetches tools from an MCP server using Google ADK's mcptoolset. // Returns the created toolset on success. func initializeToolSet(ctx context.Context, params mcpServerParams, toolFilter map[string]bool) (tool.Toolset, error) { diff --git a/go/adk/pkg/streaming/nats.go b/go/adk/pkg/streaming/nats.go new file mode 100644 index 000000000..d6307ec4d --- /dev/null +++ b/go/adk/pkg/streaming/nats.go @@ -0,0 +1,79 @@ +package streaming + +import ( + "encoding/json" + "fmt" + + "github.com/nats-io/nats.go" +) + +// StreamPublisher publishes streaming events to NATS subjects. +type StreamPublisher struct { + conn *nats.Conn +} + +// NewStreamPublisher creates a publisher backed by the given NATS connection. +func NewStreamPublisher(conn *nats.Conn) *StreamPublisher { + return &StreamPublisher{conn: conn} +} + +// PublishToken publishes an LLM token event to the given subject. +func (p *StreamPublisher) PublishToken(subject string, token *StreamEvent) error { + return p.publish(subject, token) +} + +// PublishToolProgress publishes a tool progress event to the given subject. +func (p *StreamPublisher) PublishToolProgress(subject string, event *StreamEvent) error { + return p.publish(subject, event) +} + +// PublishApprovalRequest publishes an HITL approval request to the given subject. +func (p *StreamPublisher) PublishApprovalRequest(subject string, req *ApprovalRequest) error { + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal approval request: %w", err) + } + event := NewStreamEvent(EventTypeApprovalRequest, string(data)) + return p.publish(subject, event) +} + +func (p *StreamPublisher) publish(subject string, event *StreamEvent) error { + data, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal stream event: %w", err) + } + if err := p.conn.Publish(subject, data); err != nil { + return fmt.Errorf("failed to publish to %s: %w", subject, err) + } + return nil +} + +// StreamSubscriber subscribes to NATS subjects for streaming events. +type StreamSubscriber struct { + conn *nats.Conn +} + +// NewStreamSubscriber creates a subscriber backed by the given NATS connection. +func NewStreamSubscriber(conn *nats.Conn) *StreamSubscriber { + return &StreamSubscriber{conn: conn} +} + +// Subscribe subscribes to a NATS subject and calls the handler for each event. +func (s *StreamSubscriber) Subscribe(subject string, handler func(*StreamEvent)) (*nats.Subscription, error) { + return s.conn.Subscribe(subject, func(msg *nats.Msg) { + var event StreamEvent + if err := json.Unmarshal(msg.Data, &event); err != nil { + return + } + handler(&event) + }) +} + +// NewNATSConnection creates a NATS connection to the given address. +func NewNATSConnection(addr string) (*nats.Conn, error) { + conn, err := nats.Connect(addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to NATS at %s: %w", addr, err) + } + return conn, nil +} diff --git a/go/adk/pkg/streaming/nats_test.go b/go/adk/pkg/streaming/nats_test.go new file mode 100644 index 000000000..aea6ba1e5 --- /dev/null +++ b/go/adk/pkg/streaming/nats_test.go @@ -0,0 +1,467 @@ +package streaming + +import ( + "encoding/json" + "sync" + "testing" + "time" + + natsserver "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +// startEmbeddedNATS starts an in-process NATS server on a random port for testing. +func startEmbeddedNATS(t *testing.T) (*natsserver.Server, string) { + t.Helper() + opts := &natsserver.Options{ + Host: "127.0.0.1", + Port: -1, // random port + NoLog: true, + NoSigs: true, + } + ns, err := natsserver.NewServer(opts) + if err != nil { + t.Fatalf("failed to create embedded NATS server: %v", err) + } + ns.Start() + if !ns.ReadyForConnections(5 * time.Second) { + t.Fatal("embedded NATS server not ready") + } + addr := ns.ClientURL() + t.Cleanup(func() { + ns.Shutdown() + ns.WaitForShutdown() + }) + return ns, addr +} + +func connectNATS(t *testing.T, addr string) *nats.Conn { + t.Helper() + conn, err := nats.Connect(addr) + if err != nil { + t.Fatalf("failed to connect to NATS: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +func TestNewStreamEvent(t *testing.T) { + before := time.Now().UnixMilli() + event := NewStreamEvent(EventTypeToken, "hello") + after := time.Now().UnixMilli() + + if event.Type != EventTypeToken { + t.Errorf("expected type %s, got %s", EventTypeToken, event.Type) + } + if event.Data != "hello" { + t.Errorf("expected data %q, got %q", "hello", event.Data) + } + if event.Timestamp < before || event.Timestamp > after { + t.Errorf("timestamp %d not in range [%d, %d]", event.Timestamp, before, after) + } +} + +func TestStreamEventSerialization(t *testing.T) { + event := &StreamEvent{ + Type: EventTypeToolStart, + Data: "my-tool", + Timestamp: 1700000000000, + } + + data, err := json.Marshal(event) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var decoded StreamEvent + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if decoded.Type != event.Type { + t.Errorf("type mismatch: got %s, want %s", decoded.Type, event.Type) + } + if decoded.Data != event.Data { + t.Errorf("data mismatch: got %q, want %q", decoded.Data, event.Data) + } + if decoded.Timestamp != event.Timestamp { + t.Errorf("timestamp mismatch: got %d, want %d", decoded.Timestamp, event.Timestamp) + } +} + +func TestSubjectForAgent(t *testing.T) { + tests := []struct { + agent string + session string + want string + }{ + {"myagent", "sess123", "agent.myagent.sess123.stream"}, + {"a", "b", "agent.a.b.stream"}, + } + for _, tt := range tests { + got := SubjectForAgent(tt.agent, tt.session) + if got != tt.want { + t.Errorf("SubjectForAgent(%q, %q) = %q, want %q", tt.agent, tt.session, got, tt.want) + } + } +} + +func TestPublishSubscribeRoundtrip(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + pubConn := connectNATS(t, addr) + subConn := connectNATS(t, addr) + + pub := NewStreamPublisher(pubConn) + sub := NewStreamSubscriber(subConn) + + subject := SubjectForAgent("testagent", "sess1") + + var received StreamEvent + var mu sync.Mutex + done := make(chan struct{}) + + subscription, err := sub.Subscribe(subject, func(event *StreamEvent) { + mu.Lock() + defer mu.Unlock() + received = *event + close(done) + }) + if err != nil { + t.Fatalf("subscribe error: %v", err) + } + defer subscription.Unsubscribe() + + // Flush to ensure subscription is active on the server + if err := subConn.Flush(); err != nil { + t.Fatalf("flush error: %v", err) + } + + event := NewStreamEvent(EventTypeToken, "hello world") + if err := pub.PublishToken(subject, event); err != nil { + t.Fatalf("publish error: %v", err) + } + if err := pubConn.Flush(); err != nil { + t.Fatalf("flush error: %v", err) + } + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for event") + } + + mu.Lock() + defer mu.Unlock() + if received.Type != EventTypeToken { + t.Errorf("received type %s, want %s", received.Type, EventTypeToken) + } + if received.Data != "hello world" { + t.Errorf("received data %q, want %q", received.Data, "hello world") + } +} + +func TestPublishToolProgress(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + pubConn := connectNATS(t, addr) + subConn := connectNATS(t, addr) + + pub := NewStreamPublisher(pubConn) + sub := NewStreamSubscriber(subConn) + + subject := SubjectForAgent("agent1", "sess2") + + var events []StreamEvent + var mu sync.Mutex + allReceived := make(chan struct{}) + + subscription, err := sub.Subscribe(subject, func(event *StreamEvent) { + mu.Lock() + defer mu.Unlock() + events = append(events, *event) + if len(events) == 2 { + close(allReceived) + } + }) + if err != nil { + t.Fatalf("subscribe error: %v", err) + } + defer subscription.Unsubscribe() + subConn.Flush() + + startEvent := NewStreamEvent(EventTypeToolStart, "calculator") + endEvent := NewStreamEvent(EventTypeToolEnd, "calculator") + + if err := pub.PublishToolProgress(subject, startEvent); err != nil { + t.Fatalf("publish tool start error: %v", err) + } + if err := pub.PublishToolProgress(subject, endEvent); err != nil { + t.Fatalf("publish tool end error: %v", err) + } + pubConn.Flush() + + select { + case <-allReceived: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for tool events") + } + + mu.Lock() + defer mu.Unlock() + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Type != EventTypeToolStart { + t.Errorf("first event type %s, want %s", events[0].Type, EventTypeToolStart) + } + if events[1].Type != EventTypeToolEnd { + t.Errorf("second event type %s, want %s", events[1].Type, EventTypeToolEnd) + } +} + +func TestPublishApprovalRequest(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + pubConn := connectNATS(t, addr) + subConn := connectNATS(t, addr) + + pub := NewStreamPublisher(pubConn) + sub := NewStreamSubscriber(subConn) + + subject := SubjectForAgent("agent1", "sess3") + + done := make(chan StreamEvent, 1) + + subscription, err := sub.Subscribe(subject, func(event *StreamEvent) { + done <- *event + }) + if err != nil { + t.Fatalf("subscribe error: %v", err) + } + defer subscription.Unsubscribe() + subConn.Flush() + + req := &ApprovalRequest{ + WorkflowID: "wf-123", + RunID: "run-456", + SessionID: "sess3", + Message: "approve tool execution?", + ToolName: "dangerous-tool", + ToolID: "tc-789", + } + if err := pub.PublishApprovalRequest(subject, req); err != nil { + t.Fatalf("publish approval request error: %v", err) + } + pubConn.Flush() + + select { + case event := <-done: + if event.Type != EventTypeApprovalRequest { + t.Errorf("event type %s, want %s", event.Type, EventTypeApprovalRequest) + } + // Verify the nested ApprovalRequest can be decoded from Data + var decoded ApprovalRequest + if err := json.Unmarshal([]byte(event.Data), &decoded); err != nil { + t.Fatalf("failed to decode approval request from event data: %v", err) + } + if decoded.WorkflowID != "wf-123" { + t.Errorf("WorkflowID %q, want %q", decoded.WorkflowID, "wf-123") + } + if decoded.ToolName != "dangerous-tool" { + t.Errorf("ToolName %q, want %q", decoded.ToolName, "dangerous-tool") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for approval request event") + } +} + +func TestSubscriptionCleanup(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + pubConn := connectNATS(t, addr) + subConn := connectNATS(t, addr) + + pub := NewStreamPublisher(pubConn) + sub := NewStreamSubscriber(subConn) + + subject := SubjectForAgent("agent1", "sess4") + + callCount := 0 + var mu sync.Mutex + + subscription, err := sub.Subscribe(subject, func(event *StreamEvent) { + mu.Lock() + defer mu.Unlock() + callCount++ + }) + if err != nil { + t.Fatalf("subscribe error: %v", err) + } + subConn.Flush() + + // Publish one event before unsubscribe + if err := pub.PublishToken(subject, NewStreamEvent(EventTypeToken, "before")); err != nil { + t.Fatalf("publish error: %v", err) + } + pubConn.Flush() + + // Wait for delivery + time.Sleep(200 * time.Millisecond) + + // Unsubscribe + if err := subscription.Unsubscribe(); err != nil { + t.Fatalf("unsubscribe error: %v", err) + } + + // Publish another event after unsubscribe + if err := pub.PublishToken(subject, NewStreamEvent(EventTypeToken, "after")); err != nil { + t.Fatalf("publish error: %v", err) + } + pubConn.Flush() + + // Wait to ensure no more events arrive + time.Sleep(200 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if callCount != 1 { + t.Errorf("expected 1 event before unsubscribe, got %d", callCount) + } +} + +func TestMultipleSubscribers(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + pubConn := connectNATS(t, addr) + sub1Conn := connectNATS(t, addr) + sub2Conn := connectNATS(t, addr) + + pub := NewStreamPublisher(pubConn) + sub1 := NewStreamSubscriber(sub1Conn) + sub2 := NewStreamSubscriber(sub2Conn) + + subject := SubjectForAgent("agent1", "sess5") + + var wg sync.WaitGroup + wg.Add(2) + + var received1, received2 StreamEvent + + s1, err := sub1.Subscribe(subject, func(event *StreamEvent) { + received1 = *event + wg.Done() + }) + if err != nil { + t.Fatalf("subscribe1 error: %v", err) + } + defer s1.Unsubscribe() + sub1Conn.Flush() + + s2, err := sub2.Subscribe(subject, func(event *StreamEvent) { + received2 = *event + wg.Done() + }) + if err != nil { + t.Fatalf("subscribe2 error: %v", err) + } + defer s2.Unsubscribe() + sub2Conn.Flush() + + event := NewStreamEvent(EventTypeToken, "broadcast") + if err := pub.PublishToken(subject, event); err != nil { + t.Fatalf("publish error: %v", err) + } + pubConn.Flush() + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for both subscribers") + } + + if received1.Data != "broadcast" { + t.Errorf("subscriber1 data %q, want %q", received1.Data, "broadcast") + } + if received2.Data != "broadcast" { + t.Errorf("subscriber2 data %q, want %q", received2.Data, "broadcast") + } +} + +func TestNewNATSConnection(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + conn, err := NewNATSConnection(addr) + if err != nil { + t.Fatalf("NewNATSConnection error: %v", err) + } + defer conn.Close() + + if !conn.IsConnected() { + t.Error("expected connection to be connected") + } +} + +func TestNewNATSConnectionBadAddr(t *testing.T) { + _, err := NewNATSConnection("nats://127.0.0.1:1") + if err == nil { + t.Error("expected error connecting to bad address") + } +} + +func TestPublishToClosedConnection(t *testing.T) { + _, addr := startEmbeddedNATS(t) + conn := connectNATS(t, addr) + pub := NewStreamPublisher(conn) + + conn.Close() + + err := pub.PublishToken("test.subject", NewStreamEvent(EventTypeToken, "data")) + if err == nil { + t.Error("expected error publishing to closed connection") + } +} + +func TestMalformedMessageIgnored(t *testing.T) { + _, addr := startEmbeddedNATS(t) + + rawConn := connectNATS(t, addr) + subConn := connectNATS(t, addr) + + sub := NewStreamSubscriber(subConn) + + subject := "test.malformed" + called := false + var mu sync.Mutex + + subscription, err := sub.Subscribe(subject, func(event *StreamEvent) { + mu.Lock() + defer mu.Unlock() + called = true + }) + if err != nil { + t.Fatalf("subscribe error: %v", err) + } + defer subscription.Unsubscribe() + subConn.Flush() + + // Publish raw malformed JSON + if err := rawConn.Publish(subject, []byte("not json")); err != nil { + t.Fatalf("publish error: %v", err) + } + rawConn.Flush() + + time.Sleep(200 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if called { + t.Error("handler should not be called for malformed messages") + } +} diff --git a/go/adk/pkg/streaming/types.go b/go/adk/pkg/streaming/types.go new file mode 100644 index 000000000..e2d25ec24 --- /dev/null +++ b/go/adk/pkg/streaming/types.go @@ -0,0 +1,66 @@ +package streaming + +import ( + "encoding/json" + "time" +) + +// StreamEvent represents a real-time event published over NATS for LLM tokens, +// tool progress, approval requests, and errors. +type StreamEvent struct { + Type EventType `json:"type"` + Data string `json:"data"` + Timestamp int64 `json:"timestamp"` +} + +// EventType classifies the kind of streaming event. +type EventType string + +const ( + EventTypeToken EventType = "token" + EventTypeToolStart EventType = "tool_start" + EventTypeToolEnd EventType = "tool_end" + EventTypeApprovalRequest EventType = "approval_request" + EventTypeCompletion EventType = "completion" + EventTypeError EventType = "error" +) + +// NewStreamEvent creates a StreamEvent with the current timestamp. +func NewStreamEvent(eventType EventType, data string) *StreamEvent { + return &StreamEvent{ + Type: eventType, + Data: data, + Timestamp: time.Now().UnixMilli(), + } +} + +// ApprovalRequest is published when a workflow requires HITL approval. +type ApprovalRequest struct { + WorkflowID string `json:"workflowID"` + RunID string `json:"runID"` + SessionID string `json:"sessionID"` + Message string `json:"message"` + ToolName string `json:"toolName,omitempty"` + ToolID string `json:"toolID,omitempty"` +} + +// ToolCallEvent carries structured tool call data for the UI. +type ToolCallEvent struct { + ID string `json:"id"` + Name string `json:"name"` + Args json.RawMessage `json:"args,omitempty"` +} + +// ToolResultEvent carries structured tool result data for the UI. +type ToolResultEvent struct { + ID string `json:"id"` + Name string `json:"name"` + Response json.RawMessage `json:"response,omitempty"` + IsError bool `json:"isError,omitempty"` +} + +// SubjectForAgent returns the NATS subject for an agent's session stream. +// Pattern: agent.{agentName}.{sessionID}.stream +func SubjectForAgent(agentName, sessionID string) string { + return "agent." + agentName + "." + sessionID + ".stream" +} diff --git a/go/adk/pkg/temporal/activities.go b/go/adk/pkg/temporal/activities.go new file mode 100644 index 000000000..8e323d4ed --- /dev/null +++ b/go/adk/pkg/temporal/activities.go @@ -0,0 +1,244 @@ +package temporal + +import ( + "context" + "encoding/json" + "fmt" + + a2atype "github.com/a2aproject/a2a-go/a2a" + "github.com/kagent-dev/kagent/go/adk/pkg/session" + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + "github.com/kagent-dev/kagent/go/adk/pkg/taskstore" + "github.com/nats-io/nats.go" +) + +// ModelInvoker invokes an LLM model with the given config and conversation history. +// The onToken callback is called for each streamed token (may be nil if streaming is not needed). +// Config and history are JSON-encoded AgentConfig and conversation history respectively. +type ModelInvoker func(ctx context.Context, config []byte, history []byte, onToken func(string)) (*LLMResponse, error) + +// ToolExecutor executes an MCP tool by name with the given JSON-encoded arguments. +// Returns the JSON-encoded result. +type ToolExecutor func(ctx context.Context, toolName string, args []byte) ([]byte, error) + +// Activities holds dependencies for all Temporal activity implementations. +type Activities struct { + sessionSvc session.SessionService + taskStore *taskstore.KAgentTaskStore + natsConn *nats.Conn + publisher *streaming.StreamPublisher + modelInvoker ModelInvoker + toolExecutor ToolExecutor +} + +// NewActivities creates a new Activities instance with the given dependencies. +func NewActivities( + sessionSvc session.SessionService, + taskStore *taskstore.KAgentTaskStore, + natsConn *nats.Conn, + modelInvoker ModelInvoker, + toolExecutor ToolExecutor, +) *Activities { + var publisher *streaming.StreamPublisher + if natsConn != nil { + publisher = streaming.NewStreamPublisher(natsConn) + } + return &Activities{ + sessionSvc: sessionSvc, + taskStore: taskStore, + natsConn: natsConn, + publisher: publisher, + modelInvoker: modelInvoker, + toolExecutor: toolExecutor, + } +} + +// SessionActivity creates or retrieves a session. +// If the session already exists, it is returned. Otherwise, a new one is created. +func (a *Activities) SessionActivity(ctx context.Context, req *SessionRequest) (*SessionResponse, error) { + if a.sessionSvc == nil { + return nil, fmt.Errorf("session service is not configured") + } + + // Try to get existing session first. + sess, err := a.sessionSvc.GetSession(ctx, req.AppName, req.UserID, req.SessionID) + if err == nil && sess != nil { + return &SessionResponse{SessionID: sess.ID, Created: false}, nil + } + + // Create a new session. + sess, err = a.sessionSvc.CreateSession(ctx, req.AppName, req.UserID, nil, req.SessionID) + if err != nil { + return nil, fmt.Errorf("failed to create session %s: %w", req.SessionID, err) + } + + return &SessionResponse{SessionID: sess.ID, Created: true}, nil +} + +// LLMInvokeActivity executes a single LLM chat completion turn. +// Tokens are streamed to NATS as they arrive. +func (a *Activities) LLMInvokeActivity(ctx context.Context, req *LLMRequest) (*LLMResponse, error) { + if a.modelInvoker == nil { + return nil, fmt.Errorf("model invoker is not configured") + } + + // Build a token callback that publishes to NATS if available. + var onToken func(string) + if a.publisher != nil && req.NATSSubject != "" { + onToken = func(token string) { + event := streaming.NewStreamEvent(streaming.EventTypeToken, token) + // Fire-and-forget: streaming errors are non-fatal. + _ = a.publisher.PublishToken(req.NATSSubject, event) + } + } + + resp, err := a.modelInvoker(ctx, req.Config, req.History, onToken) + if err != nil { + // Publish error event to NATS if available. + if a.publisher != nil && req.NATSSubject != "" { + errEvent := streaming.NewStreamEvent(streaming.EventTypeError, err.Error()) + _ = a.publisher.PublishToolProgress(req.NATSSubject, errEvent) + } + return nil, fmt.Errorf("LLM invocation failed: %w", err) + } + + return resp, nil +} + +// ToolExecuteActivity executes a single MCP tool call. +// Publishes structured tool_start/tool_end events to NATS so the UI can +// render tool call widgets with name, args, and results. +func (a *Activities) ToolExecuteActivity(ctx context.Context, req *ToolRequest) (*ToolResponse, error) { + if a.toolExecutor == nil { + return nil, fmt.Errorf("tool executor is not configured") + } + + // Publish tool_start event with structured tool call data. + if a.publisher != nil && req.NATSSubject != "" { + callEvent := streaming.ToolCallEvent{ + ID: req.ToolCallID, + Name: req.ToolName, + Args: req.Args, + } + callData, _ := json.Marshal(callEvent) + startEvent := streaming.NewStreamEvent(streaming.EventTypeToolStart, string(callData)) + _ = a.publisher.PublishToolProgress(req.NATSSubject, startEvent) + } + + result, err := a.toolExecutor(ctx, req.ToolName, req.Args) + + // Publish tool_end event with structured result data. + if a.publisher != nil && req.NATSSubject != "" { + resultEvent := streaming.ToolResultEvent{ + ID: req.ToolCallID, + Name: req.ToolName, + } + if err != nil { + resultEvent.IsError = true + errResp, _ := json.Marshal(map[string]any{"result": err.Error()}) + resultEvent.Response = errResp + } else { + resultEvent.Response = result + } + resultData, _ := json.Marshal(resultEvent) + endEvent := streaming.NewStreamEvent(streaming.EventTypeToolEnd, string(resultData)) + _ = a.publisher.PublishToolProgress(req.NATSSubject, endEvent) + } + + if err != nil { + return &ToolResponse{ + ToolCallID: req.ToolCallID, + Error: err.Error(), + }, nil // Return tool error in response, not as activity error (no retry). + } + + return &ToolResponse{ + ToolCallID: req.ToolCallID, + Result: result, + }, nil +} + +// SaveTaskActivity persists an A2A task. +func (a *Activities) SaveTaskActivity(ctx context.Context, req *TaskSaveRequest) error { + if a.taskStore == nil { + return fmt.Errorf("task store is not configured") + } + + var task a2atype.Task + if err := json.Unmarshal(req.TaskData, &task); err != nil { + return fmt.Errorf("failed to unmarshal task data: %w", err) + } + + if err := a.taskStore.Save(ctx, &task); err != nil { + return fmt.Errorf("failed to save task for session %s: %w", req.SessionID, err) + } + + return nil +} + +// PublishApprovalActivity publishes an HITL approval request to NATS. +// This is an activity (not workflow.SideEffect) because it needs the NATS connection, +// which is external I/O that cannot be performed inside a deterministic workflow. +func (a *Activities) PublishApprovalActivity(ctx context.Context, req *PublishApprovalRequest) error { + if a.publisher == nil { + // No NATS connection -- skip publishing. The workflow will still wait for the signal. + return nil + } + + approvalReq := &streaming.ApprovalRequest{ + WorkflowID: req.WorkflowID, + RunID: req.RunID, + SessionID: req.SessionID, + Message: req.Message, + } + // Fire-and-forget: if publishing fails, the signal can still be sent via HTTP API. + _ = a.publisher.PublishApprovalRequest(req.NATSSubject, approvalReq) + return nil +} + +// PublishCompletionActivity publishes a message completion event to NATS. +// This tells the executor that processing for the current message is done. +func (a *Activities) PublishCompletionActivity(ctx context.Context, req *PublishCompletionRequest) error { + if a.publisher == nil { + return nil + } + + result := &ExecutionResult{ + SessionID: req.SessionID, + Status: req.Status, + Response: req.Response, + Reason: req.Reason, + } + resultBytes, _ := json.Marshal(result) + event := streaming.NewStreamEvent(streaming.EventTypeCompletion, string(resultBytes)) + _ = a.publisher.PublishToolProgress(req.NATSSubject, event) + return nil +} + +// AppendEventActivity appends an event to a session. +func (a *Activities) AppendEventActivity(ctx context.Context, req *AppendEventRequest) error { + if a.sessionSvc == nil { + return fmt.Errorf("session service is not configured") + } + + // Unmarshal event from JSON to generic map for the session service. + var event any + if err := json.Unmarshal(req.Event, &event); err != nil { + return fmt.Errorf("failed to unmarshal event: %w", err) + } + + // Get the session to pass to AppendEvent. + sess, err := a.sessionSvc.GetSession(ctx, req.AppName, req.UserID, req.SessionID) + if err != nil { + return fmt.Errorf("failed to get session %s for event append: %w", req.SessionID, err) + } + if sess == nil { + return fmt.Errorf("session %s not found", req.SessionID) + } + + if err := a.sessionSvc.AppendEvent(ctx, sess, event); err != nil { + return fmt.Errorf("failed to append event to session %s: %w", req.SessionID, err) + } + + return nil +} diff --git a/go/adk/pkg/temporal/activities_test.go b/go/adk/pkg/temporal/activities_test.go new file mode 100644 index 000000000..b71b704d9 --- /dev/null +++ b/go/adk/pkg/temporal/activities_test.go @@ -0,0 +1,710 @@ +package temporal + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/adk/pkg/session" + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + "github.com/kagent-dev/kagent/go/adk/pkg/taskstore" + natsserver "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +// startEmbeddedNATS starts an in-process NATS server on a random port for testing. +func startEmbeddedNATS(t *testing.T) (*natsserver.Server, string) { + t.Helper() + opts := &natsserver.Options{ + Host: "127.0.0.1", + Port: -1, + NoLog: true, + NoSigs: true, + } + ns, err := natsserver.NewServer(opts) + if err != nil { + t.Fatalf("failed to create embedded NATS server: %v", err) + } + ns.Start() + if !ns.ReadyForConnections(5 * time.Second) { + t.Fatal("embedded NATS server not ready") + } + t.Cleanup(func() { + ns.Shutdown() + ns.WaitForShutdown() + }) + return ns, ns.ClientURL() +} + +func connectNATS(t *testing.T, addr string) *nats.Conn { + t.Helper() + conn, err := nats.Connect(addr) + if err != nil { + t.Fatalf("failed to connect to NATS: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +// mockSessionService implements session.SessionService for testing. +type mockSessionService struct { + sessions map[string]*session.Session + events map[string][]any + mu sync.Mutex + + createErr error + getErr error + appendErr error + getReturnsNil bool +} + +func newMockSessionService() *mockSessionService { + return &mockSessionService{ + sessions: make(map[string]*session.Session), + events: make(map[string][]any), + } +} + +func (m *mockSessionService) CreateSession(_ context.Context, appName, userID string, state map[string]any, sessionID string) (*session.Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.createErr != nil { + return nil, m.createErr + } + sess := &session.Session{ + ID: sessionID, + UserID: userID, + AppName: appName, + State: state, + } + m.sessions[sessionID] = sess + return sess, nil +} + +func (m *mockSessionService) GetSession(_ context.Context, appName, userID, sessionID string) (*session.Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.getErr != nil { + return nil, m.getErr + } + if m.getReturnsNil { + return nil, nil + } + sess, ok := m.sessions[sessionID] + if !ok { + return nil, nil + } + return sess, nil +} + +func (m *mockSessionService) DeleteSession(_ context.Context, _, _, sessionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, sessionID) + return nil +} + +func (m *mockSessionService) AppendEvent(_ context.Context, sess *session.Session, event any) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.appendErr != nil { + return m.appendErr + } + m.events[sess.ID] = append(m.events[sess.ID], event) + return nil +} + +func TestSessionActivity_CreateNew(t *testing.T) { + svc := newMockSessionService() + act := NewActivities(svc, nil, nil, nil, nil) + + resp, err := act.SessionActivity(context.Background(), &SessionRequest{ + AppName: "test-app", + UserID: "user1", + SessionID: "sess-123", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.SessionID != "sess-123" { + t.Errorf("got sessionID=%q, want %q", resp.SessionID, "sess-123") + } + if !resp.Created { + t.Error("expected Created=true for new session") + } +} + +func TestSessionActivity_GetExisting(t *testing.T) { + svc := newMockSessionService() + // Pre-populate session. + svc.sessions["sess-existing"] = &session.Session{ + ID: "sess-existing", + UserID: "user1", + AppName: "test-app", + } + + act := NewActivities(svc, nil, nil, nil, nil) + + resp, err := act.SessionActivity(context.Background(), &SessionRequest{ + AppName: "test-app", + UserID: "user1", + SessionID: "sess-existing", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.SessionID != "sess-existing" { + t.Errorf("got sessionID=%q, want %q", resp.SessionID, "sess-existing") + } + if resp.Created { + t.Error("expected Created=false for existing session") + } +} + +func TestSessionActivity_CreateError(t *testing.T) { + svc := newMockSessionService() + svc.getReturnsNil = true + svc.createErr = fmt.Errorf("db error") + + act := NewActivities(svc, nil, nil, nil, nil) + + _, err := act.SessionActivity(context.Background(), &SessionRequest{ + AppName: "test-app", + UserID: "user1", + SessionID: "sess-fail", + }) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSessionActivity_NilService(t *testing.T) { + act := NewActivities(nil, nil, nil, nil, nil) + + _, err := act.SessionActivity(context.Background(), &SessionRequest{ + SessionID: "sess-123", + }) + if err == nil { + t.Fatal("expected error for nil session service") + } +} + +func TestLLMInvokeActivity_Success(t *testing.T) { + invoker := func(_ context.Context, config, history []byte, onToken func(string)) (*LLMResponse, error) { + if onToken != nil { + onToken("Hello") + onToken(" world") + } + return &LLMResponse{ + Content: "Hello world", + Terminal: true, + }, nil + } + + act := NewActivities(nil, nil, nil, invoker, nil) + + resp, err := act.LLMInvokeActivity(context.Background(), &LLMRequest{ + Config: []byte(`{}`), + History: []byte(`[]`), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "Hello world" { + t.Errorf("got content=%q, want %q", resp.Content, "Hello world") + } + if !resp.Terminal { + t.Error("expected Terminal=true") + } +} + +func TestLLMInvokeActivity_WithNATSStreaming(t *testing.T) { + _, addr := startEmbeddedNATS(t) + conn := connectNATS(t, addr) + + subject := "agent.test.sess1.stream" + var received []streaming.StreamEvent + var mu sync.Mutex + + sub, err := conn.Subscribe(subject, func(msg *nats.Msg) { + var evt streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &evt); err == nil { + mu.Lock() + received = append(received, evt) + mu.Unlock() + } + }) + if err != nil { + t.Fatalf("failed to subscribe: %v", err) + } + defer sub.Unsubscribe() + + invoker := func(_ context.Context, _, _ []byte, onToken func(string)) (*LLMResponse, error) { + if onToken != nil { + onToken("tok1") + onToken("tok2") + } + return &LLMResponse{Content: "tok1tok2", Terminal: true}, nil + } + + act := NewActivities(nil, nil, conn, invoker, nil) + + resp, err := act.LLMInvokeActivity(context.Background(), &LLMRequest{ + Config: []byte(`{}`), + History: []byte(`[]`), + NATSSubject: subject, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Content != "tok1tok2" { + t.Errorf("got content=%q, want %q", resp.Content, "tok1tok2") + } + + // Flush and wait for messages. + conn.Flush() + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 2 { + t.Fatalf("expected 2 NATS events, got %d", len(received)) + } + for _, evt := range received { + if evt.Type != streaming.EventTypeToken { + t.Errorf("expected event type %q, got %q", streaming.EventTypeToken, evt.Type) + } + } + if received[0].Data != "tok1" || received[1].Data != "tok2" { + t.Errorf("unexpected token data: %q, %q", received[0].Data, received[1].Data) + } +} + +func TestLLMInvokeActivity_Error(t *testing.T) { + invoker := func(_ context.Context, _, _ []byte, _ func(string)) (*LLMResponse, error) { + return nil, fmt.Errorf("model unavailable") + } + + act := NewActivities(nil, nil, nil, invoker, nil) + + _, err := act.LLMInvokeActivity(context.Background(), &LLMRequest{ + Config: []byte(`{}`), + History: []byte(`[]`), + }) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestLLMInvokeActivity_NilInvoker(t *testing.T) { + act := NewActivities(nil, nil, nil, nil, nil) + + _, err := act.LLMInvokeActivity(context.Background(), &LLMRequest{}) + if err == nil { + t.Fatal("expected error for nil model invoker") + } +} + +func TestLLMInvokeActivity_ErrorPublishesToNATS(t *testing.T) { + _, addr := startEmbeddedNATS(t) + conn := connectNATS(t, addr) + + subject := "agent.test.sess-err.stream" + var received []streaming.StreamEvent + var mu sync.Mutex + + sub, err := conn.Subscribe(subject, func(msg *nats.Msg) { + var evt streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &evt); err == nil { + mu.Lock() + received = append(received, evt) + mu.Unlock() + } + }) + if err != nil { + t.Fatalf("failed to subscribe: %v", err) + } + defer sub.Unsubscribe() + + invoker := func(_ context.Context, _, _ []byte, _ func(string)) (*LLMResponse, error) { + return nil, fmt.Errorf("model crashed") + } + + act := NewActivities(nil, nil, conn, invoker, nil) + + _, err = act.LLMInvokeActivity(context.Background(), &LLMRequest{ + NATSSubject: subject, + }) + if err == nil { + t.Fatal("expected error") + } + + conn.Flush() + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 { + t.Fatalf("expected 1 error event, got %d", len(received)) + } + if received[0].Type != streaming.EventTypeError { + t.Errorf("expected error event type, got %q", received[0].Type) + } +} + +func TestToolExecuteActivity_Success(t *testing.T) { + executor := func(_ context.Context, toolName string, args []byte) ([]byte, error) { + return []byte(`{"result": "ok"}`), nil + } + + act := NewActivities(nil, nil, nil, nil, executor) + + resp, err := act.ToolExecuteActivity(context.Background(), &ToolRequest{ + ToolName: "my-tool", + ToolCallID: "call-1", + Args: []byte(`{"key": "value"}`), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.ToolCallID != "call-1" { + t.Errorf("got toolCallID=%q, want %q", resp.ToolCallID, "call-1") + } + if string(resp.Result) != `{"result": "ok"}` { + t.Errorf("unexpected result: %s", resp.Result) + } + if resp.Error != "" { + t.Errorf("unexpected error in response: %s", resp.Error) + } +} + +func TestToolExecuteActivity_ToolError(t *testing.T) { + executor := func(_ context.Context, toolName string, args []byte) ([]byte, error) { + return nil, fmt.Errorf("tool failed") + } + + act := NewActivities(nil, nil, nil, nil, executor) + + resp, err := act.ToolExecuteActivity(context.Background(), &ToolRequest{ + ToolName: "bad-tool", + ToolCallID: "call-2", + }) + // Tool errors are returned in the response, not as activity errors. + if err != nil { + t.Fatalf("unexpected activity error: %v", err) + } + if resp.Error != "tool failed" { + t.Errorf("expected tool error in response, got %q", resp.Error) + } +} + +func TestToolExecuteActivity_WithNATSEvents(t *testing.T) { + _, addr := startEmbeddedNATS(t) + conn := connectNATS(t, addr) + + subject := "agent.test.sess-tool.stream" + var received []streaming.StreamEvent + var mu sync.Mutex + + sub, err := conn.Subscribe(subject, func(msg *nats.Msg) { + var evt streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &evt); err == nil { + mu.Lock() + received = append(received, evt) + mu.Unlock() + } + }) + if err != nil { + t.Fatalf("failed to subscribe: %v", err) + } + defer sub.Unsubscribe() + + executor := func(_ context.Context, toolName string, args []byte) ([]byte, error) { + return []byte(`"done"`), nil + } + + act := NewActivities(nil, nil, conn, nil, executor) + + _, err = act.ToolExecuteActivity(context.Background(), &ToolRequest{ + ToolName: "my-tool", + ToolCallID: "call-3", + NATSSubject: subject, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + conn.Flush() + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 2 { + t.Fatalf("expected 2 events (start+end), got %d", len(received)) + } + if received[0].Type != streaming.EventTypeToolStart { + t.Errorf("expected tool_start, got %q", received[0].Type) + } + // Start event now carries structured JSON with tool call details. + var callEvent streaming.ToolCallEvent + if err := json.Unmarshal([]byte(received[0].Data), &callEvent); err != nil { + t.Fatalf("failed to parse start event data: %v", err) + } + if callEvent.Name != "my-tool" { + t.Errorf("expected tool name 'my-tool', got %q", callEvent.Name) + } + if callEvent.ID != "call-3" { + t.Errorf("expected tool call ID 'call-3', got %q", callEvent.ID) + } + if received[1].Type != streaming.EventTypeToolEnd { + t.Errorf("expected tool_end, got %q", received[1].Type) + } + // End event carries structured JSON with result. + var resultEvent streaming.ToolResultEvent + if err := json.Unmarshal([]byte(received[1].Data), &resultEvent); err != nil { + t.Fatalf("failed to parse end event data: %v", err) + } + if resultEvent.Name != "my-tool" { + t.Errorf("expected tool name 'my-tool' in result, got %q", resultEvent.Name) + } + if resultEvent.IsError { + t.Error("expected IsError=false in result") + } +} + +func TestToolExecuteActivity_NilExecutor(t *testing.T) { + act := NewActivities(nil, nil, nil, nil, nil) + + _, err := act.ToolExecuteActivity(context.Background(), &ToolRequest{ + ToolName: "test", + }) + if err == nil { + t.Fatal("expected error for nil tool executor") + } +} + +func TestSaveTaskActivity_Success(t *testing.T) { + // We can't easily mock KAgentTaskStore (concrete type, HTTP-based). + // Test the nil-store error path instead. + act := NewActivities(nil, nil, nil, nil, nil) + + err := act.SaveTaskActivity(context.Background(), &TaskSaveRequest{ + SessionID: "sess-1", + TaskData: []byte(`{"id": "task-1"}`), + }) + if err == nil { + t.Fatal("expected error for nil task store") + } +} + +func TestSaveTaskActivity_InvalidJSON(t *testing.T) { + // Use a real KAgentTaskStore pointing to a dummy URL. + // The unmarshal error happens before any HTTP call. + store := taskstore.NewKAgentTaskStoreWithClient("http://localhost:0", nil) + act := NewActivities(nil, store, nil, nil, nil) + + err := act.SaveTaskActivity(context.Background(), &TaskSaveRequest{ + SessionID: "sess-1", + TaskData: []byte(`not valid json`), + }) + if err == nil { + t.Fatal("expected error for invalid task JSON") + } +} + +func TestAppendEventActivity_Success(t *testing.T) { + svc := newMockSessionService() + svc.sessions["sess-1"] = &session.Session{ + ID: "sess-1", + UserID: "user1", + AppName: "app", + } + + act := NewActivities(svc, nil, nil, nil, nil) + + event := map[string]any{"type": "message", "content": "hello"} + eventData, _ := json.Marshal(event) + + err := act.AppendEventActivity(context.Background(), &AppendEventRequest{ + SessionID: "sess-1", + Event: eventData, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + svc.mu.Lock() + defer svc.mu.Unlock() + if len(svc.events["sess-1"]) != 1 { + t.Fatalf("expected 1 event, got %d", len(svc.events["sess-1"])) + } +} + +func TestAppendEventActivity_NilService(t *testing.T) { + act := NewActivities(nil, nil, nil, nil, nil) + + err := act.AppendEventActivity(context.Background(), &AppendEventRequest{ + SessionID: "sess-1", + Event: []byte(`{}`), + }) + if err == nil { + t.Fatal("expected error for nil session service") + } +} + +func TestAppendEventActivity_InvalidJSON(t *testing.T) { + svc := newMockSessionService() + act := NewActivities(svc, nil, nil, nil, nil) + + err := act.AppendEventActivity(context.Background(), &AppendEventRequest{ + SessionID: "sess-1", + Event: []byte(`not json`), + }) + if err == nil { + t.Fatal("expected error for invalid event JSON") + } +} + +func TestAppendEventActivity_SessionNotFound(t *testing.T) { + svc := newMockSessionService() + // No sessions pre-populated, GetSession returns nil. + act := NewActivities(svc, nil, nil, nil, nil) + + err := act.AppendEventActivity(context.Background(), &AppendEventRequest{ + SessionID: "nonexistent", + Event: []byte(`{"type": "test"}`), + }) + // GetSession returns nil session, which will cause AppendEvent to fail. + if err == nil { + t.Fatal("expected error for nil session") + } +} + +func TestPublishApprovalActivity_Success(t *testing.T) { + _, addr := startEmbeddedNATS(t) + conn := connectNATS(t, addr) + + subject := "agent.test.sess-approval.stream" + var received []streaming.StreamEvent + var mu sync.Mutex + + sub, err := conn.Subscribe(subject, func(msg *nats.Msg) { + var evt streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &evt); err == nil { + mu.Lock() + received = append(received, evt) + mu.Unlock() + } + }) + if err != nil { + t.Fatalf("failed to subscribe: %v", err) + } + defer sub.Unsubscribe() + + act := NewActivities(nil, nil, conn, nil, nil) + + err = act.PublishApprovalActivity(context.Background(), &PublishApprovalRequest{ + WorkflowID: "wf-123", + RunID: "run-456", + SessionID: "sess-approval", + Message: "Delete this file?", + NATSSubject: subject, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + conn.Flush() + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 { + t.Fatalf("expected 1 approval request event, got %d", len(received)) + } + if received[0].Type != streaming.EventTypeApprovalRequest { + t.Errorf("expected approval_request event, got %q", received[0].Type) + } + // The Data field contains the JSON-encoded ApprovalRequest. + var approvalReq streaming.ApprovalRequest + if err := json.Unmarshal([]byte(received[0].Data), &approvalReq); err != nil { + t.Fatalf("failed to unmarshal approval request from event data: %v", err) + } + if approvalReq.WorkflowID != "wf-123" { + t.Errorf("got workflowID=%q, want %q", approvalReq.WorkflowID, "wf-123") + } + if approvalReq.Message != "Delete this file?" { + t.Errorf("got message=%q, want %q", approvalReq.Message, "Delete this file?") + } +} + +func TestPublishApprovalActivity_NilPublisher(t *testing.T) { + // No NATS connection -- should succeed silently. + act := NewActivities(nil, nil, nil, nil, nil) + + err := act.PublishApprovalActivity(context.Background(), &PublishApprovalRequest{ + WorkflowID: "wf-123", + SessionID: "sess-1", + Message: "Approve?", + NATSSubject: "test.subject", + }) + if err != nil { + t.Fatalf("expected no error for nil publisher, got: %v", err) + } +} + +func TestToolExecuteActivity_ErrorPublishesEndEvent(t *testing.T) { + _, addr := startEmbeddedNATS(t) + conn := connectNATS(t, addr) + + subject := "agent.test.sess-tool-err.stream" + var received []streaming.StreamEvent + var mu sync.Mutex + + sub, err := conn.Subscribe(subject, func(msg *nats.Msg) { + var evt streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &evt); err == nil { + mu.Lock() + received = append(received, evt) + mu.Unlock() + } + }) + if err != nil { + t.Fatalf("failed to subscribe: %v", err) + } + defer sub.Unsubscribe() + + executor := func(_ context.Context, _ string, _ []byte) ([]byte, error) { + return nil, fmt.Errorf("execution failed") + } + + act := NewActivities(nil, nil, conn, nil, executor) + + resp, err := act.ToolExecuteActivity(context.Background(), &ToolRequest{ + ToolName: "fail-tool", + ToolCallID: "call-err", + NATSSubject: subject, + }) + if err != nil { + t.Fatalf("unexpected activity error: %v", err) + } + if resp.Error == "" { + t.Error("expected tool error in response") + } + + conn.Flush() + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 2 { + t.Fatalf("expected 2 events (start+end), got %d", len(received)) + } + // End event should contain error info. + if received[1].Type != streaming.EventTypeToolEnd { + t.Errorf("expected tool_end, got %q", received[1].Type) + } +} diff --git a/go/adk/pkg/temporal/client.go b/go/adk/pkg/temporal/client.go new file mode 100644 index 000000000..eba3a88a3 --- /dev/null +++ b/go/adk/pkg/temporal/client.go @@ -0,0 +1,167 @@ +package temporal + +import ( + "context" + "fmt" + + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/client" +) + +// Client wraps a Temporal client with agent-specific workflow operations. +type Client struct { + temporal client.Client +} + +// NewClient creates a new Temporal client connected to the given address. +func NewClient(cfg ClientConfig) (*Client, error) { + c, err := client.Dial(client.Options{ + HostPort: cfg.TemporalAddr, + Namespace: cfg.Namespace, + }) + if err != nil { + return nil, fmt.Errorf("failed to create temporal client: %w", err) + } + return &Client{temporal: c}, nil +} + +// NewClientFromExisting wraps an existing Temporal client. +func NewClientFromExisting(c client.Client) *Client { + return &Client{temporal: c} +} + +// ExecuteAgent sends a message to a session workflow using SignalWithStartWorkflow. +// If the workflow is already running, the message is delivered as a signal. +// If not, a new workflow is started and the message is delivered atomically. +// This ensures one workflow per session with multiple LLM invocations. +func (c *Client) ExecuteAgent(ctx context.Context, req *ExecutionRequest, cfg TemporalConfig) (client.WorkflowRun, error) { + taskQueue := cfg.TaskQueue + if taskQueue == "" { + taskQueue = TaskQueueForAgent(req.AgentName) + } + workflowID := WorkflowIDForSession(taskQueue, req.SessionID) + + msg := MessageSignal{ + Message: req.Message, + NATSSubject: req.NATSSubject, + } + + opts := client.StartWorkflowOptions{ + ID: workflowID, + TaskQueue: taskQueue, + WorkflowExecutionTimeout: cfg.WorkflowTimeout, + } + + run, err := c.temporal.SignalWithStartWorkflow(ctx, workflowID, MessageSignalName, msg, opts, AgentExecutionWorkflow, req) + if err != nil { + return nil, fmt.Errorf("failed to signal-with-start workflow %s: %w", workflowID, err) + } + return run, nil +} + +// SignalApproval sends an HITL approval signal to a running workflow. +func (c *Client) SignalApproval(ctx context.Context, workflowID string, decision *ApprovalDecision) error { + return c.temporal.SignalWorkflow(ctx, workflowID, "", ApprovalSignalName, decision) +} + +// GetWorkflowStatus queries the current status of a workflow execution. +func (c *Client) GetWorkflowStatus(ctx context.Context, workflowID string) (*WorkflowStatus, error) { + resp, err := c.temporal.DescribeWorkflowExecution(ctx, workflowID, "") + if err != nil { + return nil, fmt.Errorf("failed to describe workflow %s: %w", workflowID, err) + } + + info := resp.GetWorkflowExecutionInfo() + if info == nil { + return nil, fmt.Errorf("no execution info for workflow %s", workflowID) + } + + return &WorkflowStatus{ + WorkflowID: info.GetExecution().GetWorkflowId(), + RunID: info.GetExecution().GetRunId(), + Status: workflowStatusString(info.GetStatus()), + TaskQueue: info.GetTaskQueue(), + }, nil +} + +// WaitForResult blocks until the workflow completes and returns the result. +func (c *Client) WaitForResult(ctx context.Context, workflowID string) (*ExecutionResult, error) { + run := c.temporal.GetWorkflow(ctx, workflowID, "") + var result ExecutionResult + if err := run.Get(ctx, &result); err != nil { + return nil, fmt.Errorf("workflow %s failed: %w", workflowID, err) + } + return &result, nil +} + +// Temporal returns the underlying Temporal SDK client for worker creation. +func (c *Client) Temporal() client.Client { + return c.temporal +} + +// TerminateRunningWorkflows terminates all running workflows on the given task queue. +// This should be called on pod startup to clean up orphaned workflows from a previous +// pod lifecycle. Workflows mid-processing have no A2A executor waiting for their +// completion events, so they must be terminated to avoid hanging in "working" state. +func (c *Client) TerminateRunningWorkflows(ctx context.Context, taskQueue string) (int, error) { + query := fmt.Sprintf("TaskQueue = %q AND ExecutionStatus = \"Running\"", taskQueue) + + terminated := 0 + var nextPageToken []byte + + for { + resp, err := c.temporal.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + Query: query, + NextPageToken: nextPageToken, + }) + if err != nil { + return terminated, fmt.Errorf("failed to list running workflows: %w", err) + } + + for _, exec := range resp.GetExecutions() { + wfID := exec.GetExecution().GetWorkflowId() + runID := exec.GetExecution().GetRunId() + err := c.temporal.TerminateWorkflow(ctx, wfID, runID, "agent pod restarted") + if err != nil { + // Log but continue — the workflow may have already completed. + continue + } + terminated++ + } + + nextPageToken = resp.GetNextPageToken() + if len(nextPageToken) == 0 { + break + } + } + + return terminated, nil +} + +// Close closes the underlying Temporal client connection. +func (c *Client) Close() { + c.temporal.Close() +} + +// workflowStatusString converts a Temporal WorkflowExecutionStatus enum to a human-readable string. +func workflowStatusString(status enumspb.WorkflowExecutionStatus) string { + switch status { + case enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: + return "running" + case enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED: + return "completed" + case enumspb.WORKFLOW_EXECUTION_STATUS_FAILED: + return "failed" + case enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED: + return "canceled" + case enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED: + return "terminated" + case enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: + return "timed_out" + case enumspb.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: + return "continued_as_new" + default: + return "unknown" + } +} diff --git a/go/adk/pkg/temporal/client_test.go b/go/adk/pkg/temporal/client_test.go new file mode 100644 index 000000000..3ebda1274 --- /dev/null +++ b/go/adk/pkg/temporal/client_test.go @@ -0,0 +1,362 @@ +package temporal + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + commonpb "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + workflowpb "go.temporal.io/api/workflow/v1" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/mocks" +) + +func TestNewClientFromExisting(t *testing.T) { + mockClient := &mocks.Client{} + c := NewClientFromExisting(mockClient) + require.NotNil(t, c) + assert.Equal(t, mockClient, c.temporal) +} + +func TestExecuteAgent(t *testing.T) { + tests := []struct { + name string + req *ExecutionRequest + cfg TemporalConfig + wantErr bool + errMsg string + }{ + { + name: "successful execution", + req: &ExecutionRequest{ + SessionID: "sess-1", + UserID: "user-1", + AgentName: "test-agent", + Message: []byte("Hello"), + Config: []byte(`{}`), + NATSSubject: "agent.test-agent.sess-1.stream", + }, + cfg: DefaultTemporalConfig(), + }, + { + name: "temporal client error", + req: &ExecutionRequest{ + SessionID: "sess-2", + AgentName: "fail-agent", + Message: []byte("Hello"), + Config: []byte(`{}`), + }, + cfg: DefaultTemporalConfig(), + wantErr: true, + errMsg: "failed to signal-with-start workflow", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.Client{} + mockRun := &mocks.WorkflowRun{} + + workflowID := WorkflowIDForSession(tt.req.AgentName, tt.req.SessionID) + + if tt.wantErr { + mockClient.On("SignalWithStartWorkflow", mock.Anything, workflowID, MessageSignalName, mock.Anything, mock.Anything, mock.Anything, tt.req). + Return(nil, fmt.Errorf("connection refused")) + } else { + mockRun.On("GetID").Return(workflowID) + mockRun.On("GetRunID").Return("run-1") + mockClient.On("SignalWithStartWorkflow", mock.Anything, workflowID, MessageSignalName, mock.Anything, mock.Anything, mock.Anything, tt.req). + Return(mockRun, nil) + } + + c := NewClientFromExisting(mockClient) + run, err := c.ExecuteAgent(context.Background(), tt.req, tt.cfg) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + assert.Nil(t, run) + } else { + require.NoError(t, err) + assert.NotNil(t, run) + assert.Equal(t, workflowID, run.GetID()) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestExecuteAgentWorkflowOptions(t *testing.T) { + mockClient := &mocks.Client{} + mockRun := &mocks.WorkflowRun{} + + req := &ExecutionRequest{ + SessionID: "sess-1", + AgentName: "my-agent", + Message: []byte("test"), + Config: []byte(`{}`), + } + cfg := DefaultTemporalConfig() + + workflowID := WorkflowIDForSession(req.AgentName, req.SessionID) + mockClient.On("SignalWithStartWorkflow", mock.Anything, workflowID, MessageSignalName, mock.Anything, mock.Anything, mock.Anything, req). + Return(mockRun, nil) + mockRun.On("GetID").Return("my-agent:sess-1") + mockRun.On("GetRunID").Return("run-1") + + c := NewClientFromExisting(mockClient) + run, err := c.ExecuteAgent(context.Background(), req, cfg) + require.NoError(t, err) + assert.Equal(t, "my-agent:sess-1", run.GetID()) + + // Verify the workflow was started with SignalWithStartWorkflow. + call := mockClient.Calls[0] + assert.Equal(t, "SignalWithStartWorkflow", call.Method) +} + +func TestSignalApproval(t *testing.T) { + tests := []struct { + name string + decision *ApprovalDecision + signalErr error + wantErr bool + }{ + { + name: "approval approved", + decision: &ApprovalDecision{Approved: true, Reason: "looks good"}, + }, + { + name: "approval rejected", + decision: &ApprovalDecision{Approved: false, Reason: "too risky"}, + }, + { + name: "signal error", + decision: &ApprovalDecision{Approved: true}, + signalErr: fmt.Errorf("workflow not found"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.Client{} + workflowID := "agent-test-agent-sess-1" + + mockClient.On("SignalWorkflow", mock.Anything, workflowID, "", ApprovalSignalName, tt.decision). + Return(tt.signalErr) + + c := NewClientFromExisting(mockClient) + err := c.SignalApproval(context.Background(), workflowID, tt.decision) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestGetWorkflowStatus(t *testing.T) { + tests := []struct { + name string + workflowID string + resp *workflowservice.DescribeWorkflowExecutionResponse + describeErr error + wantStatus string + wantErr bool + errMsg string + }{ + { + name: "running workflow", + workflowID: "agent-test-sess-1", + resp: &workflowservice.DescribeWorkflowExecutionResponse{ + WorkflowExecutionInfo: &workflowpb.WorkflowExecutionInfo{ + Execution: &commonpb.WorkflowExecution{ + WorkflowId: "agent-test-sess-1", + RunId: "run-abc", + }, + Status: enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING, + TaskQueue: "agent-test", + }, + }, + wantStatus: "running", + }, + { + name: "completed workflow", + workflowID: "agent-test-sess-2", + resp: &workflowservice.DescribeWorkflowExecutionResponse{ + WorkflowExecutionInfo: &workflowpb.WorkflowExecutionInfo{ + Execution: &commonpb.WorkflowExecution{ + WorkflowId: "agent-test-sess-2", + RunId: "run-def", + }, + Status: enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED, + TaskQueue: "agent-test", + }, + }, + wantStatus: "completed", + }, + { + name: "failed workflow", + workflowID: "agent-test-sess-3", + resp: &workflowservice.DescribeWorkflowExecutionResponse{ + WorkflowExecutionInfo: &workflowpb.WorkflowExecutionInfo{ + Execution: &commonpb.WorkflowExecution{ + WorkflowId: "agent-test-sess-3", + RunId: "run-ghi", + }, + Status: enumspb.WORKFLOW_EXECUTION_STATUS_FAILED, + }, + }, + wantStatus: "failed", + }, + { + name: "timed out workflow", + workflowID: "agent-test-sess-4", + resp: &workflowservice.DescribeWorkflowExecutionResponse{ + WorkflowExecutionInfo: &workflowpb.WorkflowExecutionInfo{ + Execution: &commonpb.WorkflowExecution{ + WorkflowId: "agent-test-sess-4", + RunId: "run-jkl", + }, + Status: enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT, + }, + }, + wantStatus: "timed_out", + }, + { + name: "describe error", + workflowID: "agent-missing", + describeErr: fmt.Errorf("workflow not found"), + wantErr: true, + errMsg: "failed to describe workflow", + }, + { + name: "nil execution info", + workflowID: "agent-nil-info", + resp: &workflowservice.DescribeWorkflowExecutionResponse{}, + wantErr: true, + errMsg: "no execution info", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.Client{} + mockClient.On("DescribeWorkflowExecution", mock.Anything, tt.workflowID, ""). + Return(tt.resp, tt.describeErr) + + c := NewClientFromExisting(mockClient) + status, err := c.GetWorkflowStatus(context.Background(), tt.workflowID) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + assert.Nil(t, status) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, status.Status) + assert.Equal(t, tt.workflowID, status.WorkflowID) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestWaitForResult(t *testing.T) { + tests := []struct { + name string + workflowID string + getErr error + wantErr bool + }{ + { + name: "successful result", + workflowID: "agent-test-sess-1", + }, + { + name: "workflow failure", + workflowID: "agent-test-sess-2", + getErr: fmt.Errorf("workflow execution failed"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.Client{} + mockRun := &mocks.WorkflowRun{} + + mockClient.On("GetWorkflow", mock.Anything, tt.workflowID, ""). + Return(mockRun) + + if tt.getErr != nil { + mockRun.On("Get", mock.Anything, mock.Anything).Return(tt.getErr) + } else { + mockRun.On("Get", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + // Populate the result pointer. + result := args.Get(1).(*ExecutionResult) + result.SessionID = "sess-1" + result.Status = "completed" + result.Response = []byte(`{"content":"Hello!"}`) + }).Return(nil) + } + + c := NewClientFromExisting(mockClient) + result, err := c.WaitForResult(context.Background(), tt.workflowID) + + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, result) + } else { + require.NoError(t, err) + assert.Equal(t, "completed", result.Status) + assert.Equal(t, "sess-1", result.SessionID) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestClose(t *testing.T) { + mockClient := &mocks.Client{} + mockClient.On("Close").Return() + + c := NewClientFromExisting(mockClient) + c.Close() + + mockClient.AssertExpectations(t) +} + +func TestWorkflowStatusString(t *testing.T) { + tests := []struct { + status enumspb.WorkflowExecutionStatus + want string + }{ + {enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING, "running"}, + {enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED, "completed"}, + {enumspb.WORKFLOW_EXECUTION_STATUS_FAILED, "failed"}, + {enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED, "canceled"}, + {enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED, "terminated"}, + {enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT, "timed_out"}, + {enumspb.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW, "continued_as_new"}, + {enumspb.WorkflowExecutionStatus(99), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, workflowStatusString(tt.status)) + }) + } +} diff --git a/go/adk/pkg/temporal/config_convert_test.go b/go/adk/pkg/temporal/config_convert_test.go new file mode 100644 index 000000000..96508ec0c --- /dev/null +++ b/go/adk/pkg/temporal/config_convert_test.go @@ -0,0 +1,87 @@ +package temporal + +import ( + "testing" + "time" + + "github.com/kagent-dev/kagent/go/api/adk" +) + +func TestFromRuntimeConfig_Nil(t *testing.T) { + cfg := FromRuntimeConfig(nil) + def := DefaultTemporalConfig() + if cfg.Namespace != def.Namespace { + t.Errorf("Expected default namespace %s, got %s", def.Namespace, cfg.Namespace) + } + if cfg.WorkflowTimeout != def.WorkflowTimeout { + t.Errorf("Expected default timeout %v, got %v", def.WorkflowTimeout, cfg.WorkflowTimeout) + } +} + +func TestFromRuntimeConfig_AllFields(t *testing.T) { + rc := &adk.TemporalRuntimeConfig{ + Enabled: true, + HostAddr: "temporal:7233", + Namespace: "prod", + TaskQueue: "agent-myagent", + NATSAddr: "nats://nats:4222", + WorkflowTimeout: "24h", + LLMMaxAttempts: 10, + ToolMaxAttempts: 5, + } + cfg := FromRuntimeConfig(rc) + + if !cfg.Enabled { + t.Error("Expected enabled=true") + } + if cfg.HostAddr != "temporal:7233" { + t.Errorf("Expected hostAddr temporal:7233, got %s", cfg.HostAddr) + } + if cfg.Namespace != "prod" { + t.Errorf("Expected namespace prod, got %s", cfg.Namespace) + } + if cfg.TaskQueue != "agent-myagent" { + t.Errorf("Expected taskQueue agent-myagent, got %s", cfg.TaskQueue) + } + if cfg.NATSAddr != "nats://nats:4222" { + t.Errorf("Expected natsAddr nats://nats:4222, got %s", cfg.NATSAddr) + } + if cfg.WorkflowTimeout != 24*time.Hour { + t.Errorf("Expected 24h timeout, got %v", cfg.WorkflowTimeout) + } + if cfg.LLMMaxAttempts != 10 { + t.Errorf("Expected 10 LLM attempts, got %d", cfg.LLMMaxAttempts) + } + if cfg.ToolMaxAttempts != 5 { + t.Errorf("Expected 5 tool attempts, got %d", cfg.ToolMaxAttempts) + } +} + +func TestFromRuntimeConfig_Defaults(t *testing.T) { + rc := &adk.TemporalRuntimeConfig{Enabled: true} + cfg := FromRuntimeConfig(rc) + def := DefaultTemporalConfig() + + if cfg.Namespace != def.Namespace { + t.Errorf("Expected default namespace %s, got %s", def.Namespace, cfg.Namespace) + } + if cfg.WorkflowTimeout != def.WorkflowTimeout { + t.Errorf("Expected default timeout %v, got %v", def.WorkflowTimeout, cfg.WorkflowTimeout) + } + if cfg.LLMMaxAttempts != def.LLMMaxAttempts { + t.Errorf("Expected default LLM attempts %d, got %d", def.LLMMaxAttempts, cfg.LLMMaxAttempts) + } +} + +func TestFromRuntimeConfig_InvalidDuration(t *testing.T) { + rc := &adk.TemporalRuntimeConfig{ + Enabled: true, + WorkflowTimeout: "invalid", + } + cfg := FromRuntimeConfig(rc) + def := DefaultTemporalConfig() + + if cfg.WorkflowTimeout != def.WorkflowTimeout { + t.Errorf("Expected default timeout for invalid duration, got %v", cfg.WorkflowTimeout) + } +} diff --git a/go/adk/pkg/temporal/types.go b/go/adk/pkg/temporal/types.go new file mode 100644 index 000000000..04f384895 --- /dev/null +++ b/go/adk/pkg/temporal/types.go @@ -0,0 +1,237 @@ +package temporal + +import ( + "time" + + "github.com/kagent-dev/kagent/go/api/adk" +) + +// ExecutionRequest is the input to AgentExecutionWorkflow. +type ExecutionRequest struct { + SessionID string `json:"sessionID"` + UserID string `json:"userID"` + AgentName string `json:"agentName"` + Message []byte `json:"message"` // serialized A2A message + Config []byte `json:"config"` // serialized AgentConfig + NATSSubject string `json:"natsSubject"` // e.g. "agent.myagent.sess123.stream" +} + +// ExecutionResult is the output of AgentExecutionWorkflow. +type ExecutionResult struct { + SessionID string `json:"sessionID"` + Status string `json:"status"` // "completed", "rejected", "failed" + Response []byte `json:"response,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// LLMRequest is the input to LLMInvokeActivity. +type LLMRequest struct { + Config []byte `json:"config"` // serialized AgentConfig (model info) + History []byte `json:"history"` // serialized conversation history + NATSSubject string `json:"natsSubject"` // for token streaming +} + +// LLMResponse is the output of LLMInvokeActivity. +type LLMResponse struct { + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"toolCalls,omitempty"` + // AgentCalls contains A2A agent invocations detected in tool calls. + AgentCalls []AgentCall `json:"agentCalls,omitempty"` + // NeedsApproval indicates HITL approval is required before continuing. + NeedsApproval bool `json:"needsApproval,omitempty"` + ApprovalMsg string `json:"approvalMsg,omitempty"` + // Terminal indicates this is the final response (no more tool calls). + Terminal bool `json:"terminal,omitempty"` +} + +// ToolCall represents a single tool invocation requested by the LLM. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Args []byte `json:"args"` // JSON-encoded arguments +} + +// AgentCall represents an A2A agent invocation (child workflow). +type AgentCall struct { + TargetAgent string `json:"targetAgent"` + Message []byte `json:"message"` +} + +// ToolRequest is the input to ToolExecuteActivity. +type ToolRequest struct { + ToolName string `json:"toolName"` + ToolCallID string `json:"toolCallID"` + Args []byte `json:"args"` + NATSSubject string `json:"natsSubject"` +} + +// ToolResponse is the output of ToolExecuteActivity. +type ToolResponse struct { + ToolCallID string `json:"toolCallID"` + Result []byte `json:"result"` + Error string `json:"error,omitempty"` +} + +// SessionRequest is the input to SessionActivity. +type SessionRequest struct { + AppName string `json:"appName"` + UserID string `json:"userID"` + SessionID string `json:"sessionID"` +} + +// SessionResponse is the output of SessionActivity. +type SessionResponse struct { + SessionID string `json:"sessionID"` + Created bool `json:"created"` +} + +// TaskSaveRequest is the input to SaveTaskActivity. +type TaskSaveRequest struct { + SessionID string `json:"sessionID"` + TaskData []byte `json:"taskData"` +} + +// AppendEventRequest is the input to AppendEventActivity. +type AppendEventRequest struct { + SessionID string `json:"sessionID"` + AppName string `json:"appName"` + UserID string `json:"userID"` + Event []byte `json:"event"` +} + +// PublishApprovalRequest is the input to PublishApprovalActivity. +type PublishApprovalRequest struct { + WorkflowID string `json:"workflowID"` + RunID string `json:"runID"` + SessionID string `json:"sessionID"` + Message string `json:"message"` + NATSSubject string `json:"natsSubject"` +} + +// PublishCompletionRequest is the input to PublishCompletionActivity. +type PublishCompletionRequest struct { + SessionID string `json:"sessionID"` + Status string `json:"status"` // "completed", "rejected", "failed" + Response []byte `json:"response,omitempty"` + Reason string `json:"reason,omitempty"` + NATSSubject string `json:"natsSubject"` +} + +// ApprovalDecision is the payload for HITL approval signals. +type ApprovalDecision struct { + Approved bool `json:"approved"` + Reason string `json:"reason,omitempty"` +} + +// WorkflowStatus represents the current state of a workflow execution. +type WorkflowStatus struct { + WorkflowID string `json:"workflowID"` + RunID string `json:"runID"` + Status string `json:"status"` // "running", "completed", "failed", "canceled", "terminated", "timed_out" + TaskQueue string `json:"taskQueue,omitempty"` +} + +// WorkerConfig holds configuration for a Temporal worker. +type WorkerConfig struct { + TemporalAddr string `json:"temporalAddr"` // e.g. "temporal-server:7233" + Namespace string `json:"namespace"` // Temporal namespace + TaskQueue string `json:"taskQueue"` // per-agent: "agent-{agentName}" + NATSAddr string `json:"natsAddr"` // e.g. "nats://nats:4222" +} + +// ClientConfig holds configuration for a Temporal client. +type ClientConfig struct { + TemporalAddr string `json:"temporalAddr"` + Namespace string `json:"namespace"` +} + +// TemporalConfig is the runtime configuration for Temporal, derived from +// the Agent CRD spec and passed to the agent pod via config.json. +type TemporalConfig struct { + Enabled bool `json:"enabled"` + HostAddr string `json:"hostAddr"` + Namespace string `json:"namespace"` + TaskQueue string `json:"taskQueue"` // "agent-{agentName}" + NATSAddr string `json:"natsAddr"` + WorkflowTimeout time.Duration `json:"workflowTimeout"` // default 3m + LLMMaxAttempts int `json:"llmMaxAttempts"` // default 5 + ToolMaxAttempts int `json:"toolMaxAttempts"` // default 3 +} + +// DefaultTemporalConfig returns a TemporalConfig with default values. +func DefaultTemporalConfig() TemporalConfig { + return TemporalConfig{ + Namespace: "default", + WorkflowTimeout: 3 * time.Minute, + LLMMaxAttempts: 5, + ToolMaxAttempts: 3, + } +} + +// TaskQueueForAgent returns the Temporal task queue name for an agent. +// Uses the Kubernetes agent name directly for readability. +func TaskQueueForAgent(agentName string) string { + return agentName +} + +// Signal names for the session workflow. +const ( + // ApprovalSignalName is the Temporal signal channel name for HITL approvals. + ApprovalSignalName = "approval" + // MessageSignalName is the signal channel for sending new messages to a running session workflow. + MessageSignalName = "message" + // CompleteSignalName is the signal channel for explicitly completing a session workflow. + CompleteSignalName = "complete" +) + +// MessageSignal is the payload sent via the message signal channel. +type MessageSignal struct { + Message []byte `json:"message"` // serialized A2A message + NATSSubject string `json:"natsSubject"` // NATS subject for streaming events back +} + +// FromRuntimeConfig converts a TemporalRuntimeConfig (from config.json) to +// a TemporalConfig (used at runtime by the workflow/worker infrastructure). +func FromRuntimeConfig(rc *adk.TemporalRuntimeConfig) TemporalConfig { + cfg := DefaultTemporalConfig() + if rc == nil { + return cfg + } + cfg.Enabled = rc.Enabled + if rc.HostAddr != "" { + cfg.HostAddr = rc.HostAddr + } + if rc.Namespace != "" { + cfg.Namespace = rc.Namespace + } + if rc.TaskQueue != "" { + cfg.TaskQueue = rc.TaskQueue + } + if rc.NATSAddr != "" { + cfg.NATSAddr = rc.NATSAddr + } + if rc.WorkflowTimeout != "" { + if d, err := time.ParseDuration(rc.WorkflowTimeout); err == nil { + cfg.WorkflowTimeout = d + } + } + if rc.LLMMaxAttempts > 0 { + cfg.LLMMaxAttempts = rc.LLMMaxAttempts + } + if rc.ToolMaxAttempts > 0 { + cfg.ToolMaxAttempts = rc.ToolMaxAttempts + } + return cfg +} + +// WorkflowIDForSession returns a deterministic workflow ID for a session. +// Format: "{agentName}:{sessionID}" — colon separator is URL-safe so Temporal UI +// deep links work (slash would break the UI's client-side routing). +func WorkflowIDForSession(agentName, sessionID string) string { + return agentName + ":" + sessionID +} + +// ChildWorkflowID returns the workflow ID for a child workflow. +func ChildWorkflowID(parentSessionID, targetAgentName string) string { + return targetAgentName + ":child:" + parentSessionID +} diff --git a/go/adk/pkg/temporal/worker.go b/go/adk/pkg/temporal/worker.go new file mode 100644 index 000000000..5517a6919 --- /dev/null +++ b/go/adk/pkg/temporal/worker.go @@ -0,0 +1,26 @@ +package temporal + +import ( + "fmt" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/worker" +) + +// NewWorker creates a Temporal worker that polls the given task queue +// and registers all workflows and activities. +func NewWorker(temporalClient client.Client, taskQueue string, activities *Activities) (worker.Worker, error) { + if temporalClient == nil { + return nil, fmt.Errorf("temporal client must not be nil") + } + if taskQueue == "" { + return nil, fmt.Errorf("task queue must not be empty") + } + + w := worker.New(temporalClient, taskQueue, worker.Options{}) + + w.RegisterWorkflow(AgentExecutionWorkflow) + w.RegisterActivity(activities) + + return w, nil +} diff --git a/go/adk/pkg/temporal/worker_test.go b/go/adk/pkg/temporal/worker_test.go new file mode 100644 index 000000000..41f84cb95 --- /dev/null +++ b/go/adk/pkg/temporal/worker_test.go @@ -0,0 +1,96 @@ +package temporal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/client" +) + +// newLazyTestClient creates a lazy Temporal client suitable for tests. +// It doesn't connect to a server, just validates worker registration. +func newLazyTestClient(t *testing.T) client.Client { + t.Helper() + c, err := client.NewLazyClient(client.Options{}) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + return c +} + +func TestNewWorker(t *testing.T) { + tests := []struct { + name string + useClient bool + taskQueue string + activities *Activities + wantErr bool + errMsg string + }{ + { + name: "valid worker creation", + useClient: true, + taskQueue: "agent-test", + activities: &Activities{}, + }, + { + name: "nil client", + useClient: false, + taskQueue: "agent-test", + activities: &Activities{}, + wantErr: true, + errMsg: "temporal client must not be nil", + }, + { + name: "empty task queue", + useClient: true, + taskQueue: "", + activities: &Activities{}, + wantErr: true, + errMsg: "task queue must not be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c client.Client + if tt.useClient { + c = newLazyTestClient(t) + } + + w, err := NewWorker(c, tt.taskQueue, tt.activities) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + assert.Nil(t, w) + } else { + require.NoError(t, err) + assert.NotNil(t, w) + } + }) + } +} + +func TestNewWorkerRegistersWorkflowAndActivities(t *testing.T) { + c := newLazyTestClient(t) + activities := &Activities{} + + w, err := NewWorker(c, "agent-test", activities) + require.NoError(t, err) + require.NotNil(t, w) + // Worker creation succeeds without panics — workflows and activities are registered. +} + +func TestNewWorkerWithDifferentTaskQueues(t *testing.T) { + c := newLazyTestClient(t) + activities := &Activities{} + + // Create workers for different agents — each gets its own task queue. + queues := []string{"agent-alpha", "agent-beta", "agent-gamma"} + for _, q := range queues { + w, err := NewWorker(c, q, activities) + require.NoError(t, err) + assert.NotNil(t, w) + } +} diff --git a/go/adk/pkg/temporal/workflows.go b/go/adk/pkg/temporal/workflows.go new file mode 100644 index 000000000..35693c74b --- /dev/null +++ b/go/adk/pkg/temporal/workflows.go @@ -0,0 +1,600 @@ +package temporal + +import ( + "encoding/json" + "fmt" + "time" + + a2atype "github.com/a2aproject/a2a-go/a2a" + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +const ( + // MaxTurns is the safety bound on the number of LLM turns per single message processing. + MaxTurns = 100 + + // SessionIdleTimeout is how long the workflow waits for a new message before exiting. + SessionIdleTimeout = 1 * time.Hour + + // DefaultLLMActivityTimeout is the per-activity timeout for LLM invocations. + DefaultLLMActivityTimeout = 5 * time.Minute + + // DefaultToolActivityTimeout is the per-activity timeout for tool executions. + DefaultToolActivityTimeout = 10 * time.Minute + + // DefaultSessionActivityTimeout is the per-activity timeout for session operations. + DefaultSessionActivityTimeout = 30 * time.Second + + // DefaultTaskActivityTimeout is the per-activity timeout for task save operations. + DefaultTaskActivityTimeout = 30 * time.Second +) + +// conversationEntry represents a single turn in the conversation history +// passed between LLM activity invocations within the workflow. +type conversationEntry struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"toolCalls,omitempty"` + ToolCallID string `json:"toolCallID,omitempty"` + ToolResult json.RawMessage `json:"toolResult,omitempty"` +} + +// AgentExecutionWorkflow is a long-running session workflow. +// It initializes a session, then loops waiting for message signals. +// Each message triggers an LLM+tool processing cycle. The workflow +// stays alive across multiple messages in the same session, producing +// a single workflow execution in the Temporal UI. +// +// Flow: +// 1. Initialize session (activity) +// 2. Drain any buffered message signal (from SignalWithStart) +// 3. Loop: wait for message signal -> LLM+tool cycle -> publish result via NATS -> repeat +// 4. Exit on idle timeout (no messages for SessionIdleTimeout) +func AgentExecutionWorkflow(ctx workflow.Context, req *ExecutionRequest) (*ExecutionResult, error) { + if req == nil { + return nil, fmt.Errorf("execution request must not be nil") + } + + config := extractTemporalConfig(req.Config) + + sessionCtx := workflow.WithActivityOptions(ctx, sessionActivityOptions()) + llmCtx := workflow.WithActivityOptions(ctx, llmActivityOptions(config)) + toolCtx := workflow.WithActivityOptions(ctx, toolActivityOptions(config)) + taskCtx := workflow.WithActivityOptions(ctx, taskActivityOptions()) + + // Step 1: Initialize session. + var activities *Activities + var sessResp SessionResponse + err := workflow.ExecuteActivity(sessionCtx, activities.SessionActivity, &SessionRequest{ + AppName: req.AgentName, + UserID: req.UserID, + SessionID: req.SessionID, + }).Get(sessionCtx, &sessResp) + if err != nil { + return nil, fmt.Errorf("session initialization failed: %w", err) + } + + // Conversation history persists across messages within the session. + var history []conversationEntry + + // Message signal channel — receives new user messages. + msgCh := workflow.GetSignalChannel(ctx, MessageSignalName) + + // Step 2: Drain the initial message from SignalWithStart (or from the req itself). + // The first message comes either via signal (SignalWithStart) or via req.Message (backward compat). + var firstMsg MessageSignal + if msgCh.ReceiveAsync(&firstMsg) { + // Got message from signal channel (SignalWithStart path). + } else if len(req.Message) > 0 { + // Backward compatibility: message in the request itself. + firstMsg = MessageSignal{ + Message: req.Message, + NATSSubject: req.NATSSubject, + } + } + + if len(firstMsg.Message) > 0 { + result, err := processMessage(ctx, llmCtx, toolCtx, taskCtx, activities, req, config, &history, &firstMsg) + if result != nil || err != nil { + return result, err + } + } + + // Complete signal channel — allows explicit session completion. + completeCh := workflow.GetSignalChannel(ctx, CompleteSignalName) + + // Step 3: Main loop — wait for new messages, complete signal, or idle timeout. + for { + var msg MessageSignal + timerCtx, cancelTimer := workflow.WithCancel(ctx) + timer := workflow.NewTimer(timerCtx, SessionIdleTimeout) + + // Create a selector to wait for a message, complete signal, or idle timeout. + sel := workflow.NewSelector(ctx) + + var gotMessage, gotComplete bool + sel.AddReceive(msgCh, func(ch workflow.ReceiveChannel, more bool) { + ch.Receive(ctx, &msg) + gotMessage = true + }) + sel.AddReceive(completeCh, func(ch workflow.ReceiveChannel, more bool) { + var reason string + ch.Receive(ctx, &reason) + gotComplete = true + }) + sel.AddFuture(timer, func(f workflow.Future) { + // Timer fired — idle timeout reached. + }) + + sel.Select(ctx) + cancelTimer() + + if gotComplete { + return &ExecutionResult{ + SessionID: req.SessionID, + Status: "completed", + Reason: "session completed by user", + }, nil + } + + if !gotMessage { + // Idle timeout — gracefully exit. + return &ExecutionResult{ + SessionID: req.SessionID, + Status: "completed", + Reason: "session idle timeout", + }, nil + } + + result, err := processMessage(ctx, llmCtx, toolCtx, taskCtx, activities, req, config, &history, &msg) + if result != nil || err != nil { + return result, err + } + } +} + +// processMessage handles a single user message through the LLM+tool loop. +func processMessage( + ctx workflow.Context, + llmCtx, toolCtx, taskCtx workflow.Context, + activities *Activities, + req *ExecutionRequest, + config TemporalConfig, + history *[]conversationEntry, + msg *MessageSignal, +) (*ExecutionResult, error) { + // Extract text from A2A message parts for the LLM conversation history. + userText := extractTextFromA2AMessage(msg.Message) + + // Add user message to conversation history. + *history = append(*history, conversationEntry{ + Role: "user", + Content: userText, + }) + + natsSubject := msg.NATSSubject + + // LLM + tool loop for this message. + for turn := 0; turn < MaxTurns; turn++ { + historyBytes, err := json.Marshal(*history) + if err != nil { + return nil, fmt.Errorf("failed to serialize history at turn %d: %w", turn, err) + } + + // Invoke LLM. + var llmResp LLMResponse + err = workflow.ExecuteActivity(llmCtx, activities.LLMInvokeActivity, &LLMRequest{ + Config: req.Config, + History: historyBytes, + NATSSubject: natsSubject, + }).Get(llmCtx, &llmResp) + if err != nil { + return &ExecutionResult{ + SessionID: req.SessionID, + Status: "failed", + Reason: fmt.Sprintf("LLM invocation failed at turn %d: %s", turn, err.Error()), + }, nil + } + + // Terminal response: no tool calls, no agent calls, no HITL. + if llmResp.Terminal || (len(llmResp.ToolCalls) == 0 && len(llmResp.AgentCalls) == 0 && !llmResp.NeedsApproval) { + *history = append(*history, conversationEntry{ + Role: "assistant", + Content: llmResp.Content, + }) + + // Build A2A task with full history including tool calls/results. + responseBytes, _ := json.Marshal(llmResp) + now := workflow.Now(ctx) + + taskHistory := buildA2AHistory(*history) + agentMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: llmResp.Content}) + + task := &a2atype.Task{ + ID: a2atype.TaskID(req.SessionID), + ContextID: req.SessionID, + History: taskHistory, + Status: a2atype.TaskStatus{ + State: a2atype.TaskStateCompleted, + Message: agentMsg, + Timestamp: &now, + }, + } + taskData, _ := json.Marshal(task) + _ = workflow.ExecuteActivity(taskCtx, activities.SaveTaskActivity, &TaskSaveRequest{ + SessionID: req.SessionID, + TaskData: taskData, + }).Get(taskCtx, nil) + + // Publish completion event via NATS so the executor knows this message is done. + _ = workflow.ExecuteActivity(taskCtx, activities.PublishCompletionActivity, &PublishCompletionRequest{ + SessionID: req.SessionID, + Status: "completed", + Response: responseBytes, + NATSSubject: natsSubject, + }).Get(taskCtx, nil) + + // Return to the main loop to wait for the next message (don't exit the workflow). + return nil, nil + } + + // Append assistant turn with tool calls to history. + *history = append(*history, conversationEntry{ + Role: "assistant", + Content: llmResp.Content, + ToolCalls: llmResp.ToolCalls, + }) + + // Execute tool calls in parallel. + if len(llmResp.ToolCalls) > 0 { + toolResults, err := executeToolsInParallel(toolCtx, activities, llmResp.ToolCalls, natsSubject) + if err != nil { + return &ExecutionResult{ + SessionID: req.SessionID, + Status: "failed", + Reason: fmt.Sprintf("tool execution failed at turn %d: %s", turn, err.Error()), + }, nil + } + + for _, tr := range toolResults { + *history = append(*history, conversationEntry{ + Role: "tool", + ToolCallID: tr.ToolCallID, + ToolResult: tr.Result, + }) + } + } + + // Handle A2A agent calls as child workflows. + if len(llmResp.AgentCalls) > 0 { + childResults, err := executeChildWorkflows(ctx, req, llmResp.AgentCalls, config) + if err != nil { + return &ExecutionResult{ + SessionID: req.SessionID, + Status: "failed", + Reason: fmt.Sprintf("child workflow failed at turn %d: %s", turn, err.Error()), + }, nil + } + + for _, cr := range childResults { + resultBytes, _ := json.Marshal(cr) + *history = append(*history, conversationEntry{ + Role: "tool", + ToolCallID: "agent-" + cr.AgentName, + ToolResult: resultBytes, + }) + } + } + + // HITL approval: block on signal. + if llmResp.NeedsApproval { + wfInfo := workflow.GetInfo(ctx) + _ = workflow.ExecuteActivity(taskCtx, activities.PublishApprovalActivity, &PublishApprovalRequest{ + WorkflowID: wfInfo.WorkflowExecution.ID, + RunID: wfInfo.WorkflowExecution.RunID, + SessionID: req.SessionID, + Message: llmResp.ApprovalMsg, + NATSSubject: natsSubject, + }).Get(taskCtx, nil) + + approvalCh := workflow.GetSignalChannel(ctx, ApprovalSignalName) + var decision ApprovalDecision + approvalCh.Receive(ctx, &decision) + + if !decision.Approved { + _ = workflow.ExecuteActivity(taskCtx, activities.PublishCompletionActivity, &PublishCompletionRequest{ + SessionID: req.SessionID, + Status: "rejected", + Reason: decision.Reason, + NATSSubject: natsSubject, + }).Get(taskCtx, nil) + return nil, nil + } + + *history = append(*history, conversationEntry{ + Role: "user", + Content: fmt.Sprintf("[APPROVED] %s", decision.Reason), + }) + } + } + + // Safety: exceeded max turns. + return &ExecutionResult{ + SessionID: req.SessionID, + Status: "failed", + Reason: fmt.Sprintf("exceeded maximum turns (%d)", MaxTurns), + }, nil +} + +// childWorkflowResult captures the outcome of a child workflow execution. +type childWorkflowResult struct { + AgentName string `json:"agentName"` + Status string `json:"status"` + Response json.RawMessage `json:"response,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// executeChildWorkflows launches child workflows for A2A agent calls in parallel +// and waits for all of them to complete. +func executeChildWorkflows(ctx workflow.Context, parentReq *ExecutionRequest, agentCalls []AgentCall, config TemporalConfig) ([]childWorkflowResult, error) { + results := make([]childWorkflowResult, len(agentCalls)) + futures := make([]workflow.ChildWorkflowFuture, len(agentCalls)) + + for i, ac := range agentCalls { + childSessionID := ChildWorkflowID(parentReq.SessionID, ac.TargetAgent) + childNATSSubject := "agent." + ac.TargetAgent + "." + childSessionID + ".stream" + + childOpts := workflow.ChildWorkflowOptions{ + TaskQueue: TaskQueueForAgent(ac.TargetAgent), + WorkflowID: childSessionID, + WorkflowExecutionTimeout: config.WorkflowTimeout, + ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_TERMINATE, + } + childCtx := workflow.WithChildOptions(ctx, childOpts) + + childReq := &ExecutionRequest{ + SessionID: childSessionID, + UserID: parentReq.UserID, + AgentName: ac.TargetAgent, + Message: ac.Message, + Config: parentReq.Config, + NATSSubject: childNATSSubject, + } + + futures[i] = workflow.ExecuteChildWorkflow(childCtx, AgentExecutionWorkflow, childReq) + } + + for i, f := range futures { + var childResult ExecutionResult + err := f.Get(ctx, &childResult) + if err != nil { + return nil, fmt.Errorf("child workflow for agent %q failed: %w", agentCalls[i].TargetAgent, err) + } + + results[i] = childWorkflowResult{ + AgentName: agentCalls[i].TargetAgent, + Status: childResult.Status, + Response: childResult.Response, + Reason: childResult.Reason, + } + } + + return results, nil +} + +// executeToolsInParallel executes multiple tool calls concurrently using workflow goroutines. +func executeToolsInParallel(ctx workflow.Context, activities *Activities, toolCalls []ToolCall, natsSubject string) ([]ToolResponse, error) { + results := make([]ToolResponse, len(toolCalls)) + errs := make([]error, len(toolCalls)) + + futures := make([]workflow.Future, len(toolCalls)) + for i, tc := range toolCalls { + futures[i] = workflow.ExecuteActivity(ctx, activities.ToolExecuteActivity, &ToolRequest{ + ToolName: tc.Name, + ToolCallID: tc.ID, + Args: tc.Args, + NATSSubject: natsSubject, + }) + } + + for i, f := range futures { + err := f.Get(ctx, &results[i]) + if err != nil { + errs[i] = err + } + } + + for _, err := range errs { + if err != nil { + return nil, err + } + } + + return results, nil +} + +// extractTemporalConfig extracts TemporalConfig from the serialized agent config. +// Returns defaults if config cannot be parsed. +func extractTemporalConfig(configBytes []byte) TemporalConfig { + cfg := DefaultTemporalConfig() + + if len(configBytes) == 0 { + return cfg + } + + var wrapper struct { + Temporal *TemporalConfig `json:"temporal"` + } + if err := json.Unmarshal(configBytes, &wrapper); err == nil && wrapper.Temporal != nil { + if wrapper.Temporal.LLMMaxAttempts > 0 { + cfg.LLMMaxAttempts = wrapper.Temporal.LLMMaxAttempts + } + if wrapper.Temporal.ToolMaxAttempts > 0 { + cfg.ToolMaxAttempts = wrapper.Temporal.ToolMaxAttempts + } + if wrapper.Temporal.WorkflowTimeout > 0 { + cfg.WorkflowTimeout = wrapper.Temporal.WorkflowTimeout + } + } + + return cfg +} + +// Activity option builders per activity type. + +func sessionActivityOptions() workflow.ActivityOptions { + return workflow.ActivityOptions{ + StartToCloseTimeout: DefaultSessionActivityTimeout, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 1 * time.Second, + MaximumInterval: 10 * time.Second, + MaximumAttempts: 3, + BackoffCoefficient: 2.0, + }, + } +} + +func llmActivityOptions(config TemporalConfig) workflow.ActivityOptions { + return workflow.ActivityOptions{ + StartToCloseTimeout: DefaultLLMActivityTimeout, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 2 * time.Second, + MaximumInterval: 2 * time.Minute, + MaximumAttempts: int32(config.LLMMaxAttempts), + BackoffCoefficient: 2.0, + }, + } +} + +func toolActivityOptions(config TemporalConfig) workflow.ActivityOptions { + return workflow.ActivityOptions{ + StartToCloseTimeout: DefaultToolActivityTimeout, + HeartbeatTimeout: 1 * time.Minute, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 1 * time.Second, + MaximumInterval: 1 * time.Minute, + MaximumAttempts: int32(config.ToolMaxAttempts), + BackoffCoefficient: 2.0, + }, + } +} + +func taskActivityOptions() workflow.ActivityOptions { + return workflow.ActivityOptions{ + StartToCloseTimeout: DefaultTaskActivityTimeout, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 1 * time.Second, + MaximumInterval: 10 * time.Second, + MaximumAttempts: 3, + BackoffCoefficient: 2.0, + }, + } +} + +// buildA2AHistory converts the internal conversation history into A2A Messages +// suitable for task persistence. Each entry becomes a properly typed message: +// user text, assistant text, function_call DataParts, and function_response DataParts. +func buildA2AHistory(history []conversationEntry) []*a2atype.Message { + // Build a mapping from tool call ID to tool name for result entries. + toolNameByID := make(map[string]string) + for _, entry := range history { + for _, tc := range entry.ToolCalls { + toolNameByID[tc.ID] = tc.Name + } + } + + var msgs []*a2atype.Message + for _, entry := range history { + switch entry.Role { + case "user": + msgs = append(msgs, a2atype.NewMessage(a2atype.MessageRoleUser, + a2atype.TextPart{Text: entry.Content})) + + case "assistant": + if len(entry.ToolCalls) > 0 { + // Emit each tool call as a separate message with function_call metadata. + for _, tc := range entry.ToolCalls { + var args map[string]any + if len(tc.Args) > 0 { + _ = json.Unmarshal(tc.Args, &args) + } + msgs = append(msgs, a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.DataPart{ + Data: map[string]any{ + "id": tc.ID, + "name": tc.Name, + "args": args, + }, + Metadata: map[string]any{"adk_type": "function_call"}, + })) + } + } + if entry.Content != "" && len(entry.ToolCalls) == 0 { + msgs = append(msgs, a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.TextPart{Text: entry.Content})) + } + + case "tool": + var result any + if len(entry.ToolResult) > 0 { + _ = json.Unmarshal(entry.ToolResult, &result) + } + toolName := toolNameByID[entry.ToolCallID] + if toolName == "" { + toolName = entry.ToolCallID + } + msgs = append(msgs, a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.DataPart{ + Data: map[string]any{ + "id": entry.ToolCallID, + "name": toolName, + "response": map[string]any{ + "isError": false, + "result": result, + }, + }, + Metadata: map[string]any{"adk_type": "function_response"}, + })) + } + } + return msgs +} + +// extractTextFromA2AMessage extracts the text content from a JSON-encoded A2A Message. +// Falls back to treating the bytes as plain text if parsing fails. +func extractTextFromA2AMessage(msgBytes []byte) string { + if len(msgBytes) == 0 { + return "" + } + + // Try to parse as an A2A Message with structured parts. + var msg struct { + Parts []json.RawMessage `json:"parts"` + } + if err := json.Unmarshal(msgBytes, &msg); err == nil && len(msg.Parts) > 0 { + var text string + for _, raw := range msg.Parts { + var part struct { + Kind string `json:"kind"` + Text string `json:"text"` + } + if json.Unmarshal(raw, &part) == nil && part.Kind == "text" { + text += part.Text + } + } + if text != "" { + return text + } + } + + // Fallback: try as a plain JSON string. + var plain string + if json.Unmarshal(msgBytes, &plain) == nil { + return plain + } + + // Last resort: use raw bytes as text. + return string(msgBytes) +} diff --git a/go/adk/pkg/temporal/workflows_test.go b/go/adk/pkg/temporal/workflows_test.go new file mode 100644 index 000000000..cda07eab5 --- /dev/null +++ b/go/adk/pkg/temporal/workflows_test.go @@ -0,0 +1,665 @@ +package temporal + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.temporal.io/sdk/testsuite" +) + +type WorkflowTestSuite struct { + suite.Suite + testsuite.WorkflowTestSuite + env *testsuite.TestWorkflowEnvironment + act *Activities // nil-receiver for bound method references in mocks +} + +func (s *WorkflowTestSuite) SetupTest() { + s.env = s.NewTestWorkflowEnvironment() + s.act = &Activities{} + s.env.RegisterActivity(s.act) +} + +func (s *WorkflowTestSuite) AfterTest(_, _ string) { + s.env.AssertExpectations(s.T()) +} + +func TestWorkflowSuite(t *testing.T) { + suite.Run(t, new(WorkflowTestSuite)) +} + +// Helper: create a basic execution request. +func basicRequest() *ExecutionRequest { + return &ExecutionRequest{ + SessionID: "sess-1", + UserID: "user-1", + AgentName: "test-agent", + Message: []byte("Hello, agent!"), + Config: []byte(`{}`), + NATSSubject: "agent.test-agent.sess-1.stream", + } +} + +// Test: simple single-turn execution (no tool calls). +// The workflow processes the message from req.Message (backward compat), +// then waits for more signals. The test env auto-advances time so the +// idle timeout fires and the workflow completes. +func (s *WorkflowTestSuite) TestSingleTurnCompletion() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "Hello! How can I help you?", + Terminal: true, + }, nil) + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: multi-turn execution with tool calls. +func (s *WorkflowTestSuite) TestMultiTurnWithToolCalls() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // First LLM turn: returns tool calls. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "", + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "get_weather", Args: []byte(`{"city":"NYC"}`)}, + }, + }, nil).Once() + + // Tool execution returns result. + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, mock.Anything). + Return(&ToolResponse{ + ToolCallID: "tc-1", + Result: []byte(`{"temp":"72F"}`), + }, nil) + + // Second LLM turn: terminal response. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "The weather in NYC is 72F.", + Terminal: true, + }, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: parallel tool execution (multiple tools in one turn). +func (s *WorkflowTestSuite) TestParallelToolExecution() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // LLM returns multiple tool calls. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "get_weather", Args: []byte(`{"city":"NYC"}`)}, + {ID: "tc-2", Name: "get_time", Args: []byte(`{"tz":"EST"}`)}, + }, + }, nil).Once() + + // Both tools execute (order doesn't matter for parallel). + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, &ToolRequest{ + ToolName: "get_weather", + ToolCallID: "tc-1", + Args: []byte(`{"city":"NYC"}`), + NATSSubject: "agent.test-agent.sess-1.stream", + }).Return(&ToolResponse{ToolCallID: "tc-1", Result: []byte(`"72F"`)}, nil) + + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, &ToolRequest{ + ToolName: "get_time", + ToolCallID: "tc-2", + Args: []byte(`{"tz":"EST"}`), + NATSSubject: "agent.test-agent.sess-1.stream", + }).Return(&ToolResponse{ToolCallID: "tc-2", Result: []byte(`"3:00 PM"`)}, nil) + + // Second LLM turn: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Weather is 72F and time is 3:00 PM.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: LLM activity failure returns failed status (not workflow error). +func (s *WorkflowTestSuite) TestLLMActivityFailure() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(nil, errLLMUnavailable) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("failed", result.Status) + s.Contains(result.Reason, "LLM invocation failed") +} + +// Test: session initialization failure causes workflow error. +func (s *WorkflowTestSuite) TestSessionInitFailure() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(nil, errSessionUnavailable) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) + s.Contains(s.env.GetWorkflowError().Error(), "session initialization failed") +} + +// Test: nil request returns error. +func (s *WorkflowTestSuite) TestNilRequest() { + s.env.ExecuteWorkflow(AgentExecutionWorkflow, (*ExecutionRequest)(nil)) + + s.True(s.env.IsWorkflowCompleted()) + s.Error(s.env.GetWorkflowError()) + s.Contains(s.env.GetWorkflowError().Error(), "execution request must not be nil") +} + +// Test: tool activity failure returns failed result. +func (s *WorkflowTestSuite) TestToolActivityFailure() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "dangerous_tool", Args: []byte(`{}`)}, + }, + }, nil) + + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, mock.Anything). + Return(nil, errToolCrash) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("failed", result.Status) + s.Contains(result.Reason, "tool execution failed") +} + +// Test: tool error in response (non-fatal) gets passed back to LLM. +func (s *WorkflowTestSuite) TestToolErrorInResponsePassedToLLM() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // First turn: LLM requests a tool. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "flaky_tool", Args: []byte(`{}`)}, + }, + }, nil).Once() + + // Tool returns an error in the response (not an activity error). + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, mock.Anything). + Return(&ToolResponse{ToolCallID: "tc-1", Error: "tool returned 404"}, nil) + + // Second turn: LLM sees the tool error and gives a final answer. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Sorry, I couldn't get that data.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: implicit terminal (no tool calls, not marked terminal). +func (s *WorkflowTestSuite) TestImplicitTerminal() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // LLM returns content with no tool calls and Terminal=false (implicit terminal). + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Here's your answer."}, nil) + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: custom retry config from agent config. +func (s *WorkflowTestSuite) TestCustomRetryConfig() { + config := map[string]interface{}{ + "temporal": map[string]interface{}{ + "llmMaxAttempts": 10, + "toolMaxAttempts": 5, + }, + } + configBytes, _ := json.Marshal(config) + + req := &ExecutionRequest{ + SessionID: "sess-1", + UserID: "user-1", + AgentName: "test-agent", + Message: []byte("test"), + Config: configBytes, + NATSSubject: "agent.test-agent.sess-1.stream", + } + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Done.", Terminal: true}, nil) + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: extractTemporalConfig with valid config. +func TestExtractTemporalConfig(t *testing.T) { + config := map[string]interface{}{ + "temporal": map[string]interface{}{ + "llmMaxAttempts": 8, + "toolMaxAttempts": 4, + }, + } + configBytes, _ := json.Marshal(config) + + cfg := extractTemporalConfig(configBytes) + if cfg.LLMMaxAttempts != 8 { + t.Errorf("expected LLMMaxAttempts=8, got %d", cfg.LLMMaxAttempts) + } + if cfg.ToolMaxAttempts != 4 { + t.Errorf("expected ToolMaxAttempts=4, got %d", cfg.ToolMaxAttempts) + } +} + +// Test: extractTemporalConfig with empty config returns defaults. +func TestExtractTemporalConfigDefaults(t *testing.T) { + cfg := extractTemporalConfig(nil) + defaults := DefaultTemporalConfig() + if cfg.LLMMaxAttempts != defaults.LLMMaxAttempts { + t.Errorf("expected default LLMMaxAttempts=%d, got %d", defaults.LLMMaxAttempts, cfg.LLMMaxAttempts) + } + if cfg.ToolMaxAttempts != defaults.ToolMaxAttempts { + t.Errorf("expected default ToolMaxAttempts=%d, got %d", defaults.ToolMaxAttempts, cfg.ToolMaxAttempts) + } +} + +// Test: extractTemporalConfig with invalid JSON returns defaults. +func TestExtractTemporalConfigInvalidJSON(t *testing.T) { + cfg := extractTemporalConfig([]byte("not json")) + defaults := DefaultTemporalConfig() + if cfg.LLMMaxAttempts != defaults.LLMMaxAttempts { + t.Errorf("expected default LLMMaxAttempts, got %d", cfg.LLMMaxAttempts) + } +} + +// Test: HITL approval signal allows workflow to continue. +func (s *WorkflowTestSuite) TestHITLApprovalContinues() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // First LLM turn: needs approval. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "I need to delete a file. Do you approve?", + NeedsApproval: true, + ApprovalMsg: "Delete important-file.txt?", + }, nil).Once() + + // Publish approval activity. + s.env.OnActivity(s.act.PublishApprovalActivity, mock.Anything, mock.Anything).Return(nil) + + // Register a callback to send the approval signal after the workflow blocks. + s.env.RegisterDelayedCallback(func() { + s.env.SignalWorkflow(ApprovalSignalName, &ApprovalDecision{ + Approved: true, + Reason: "User approved the deletion", + }) + }, 0) + + // Second LLM turn after approval: terminal response. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "File deleted successfully.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: HITL rejection signal publishes completion and returns to message loop. +func (s *WorkflowTestSuite) TestHITLRejectionStopsWorkflow() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // LLM turn: needs approval. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "I need to delete a file.", + NeedsApproval: true, + ApprovalMsg: "Delete important-file.txt?", + }, nil) + + s.env.OnActivity(s.act.PublishApprovalActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + // Send rejection signal. + s.env.RegisterDelayedCallback(func() { + s.env.SignalWorkflow(ApprovalSignalName, &ApprovalDecision{ + Approved: false, + Reason: "Too dangerous", + }) + }, 0) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + // After rejection, processMessage returns nil,nil and workflow enters idle loop. + // Test env auto-advances time, so idle timeout fires. + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: HITL approval after tool calls in the same turn. +func (s *WorkflowTestSuite) TestHITLAfterToolCalls() { + req := basicRequest() + + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // First turn: tool calls + needs approval. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "check_file", Args: []byte(`{"path":"important.txt"}`)}, + }, + NeedsApproval: true, + ApprovalMsg: "Found file. Delete it?", + }, nil).Once() + + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, mock.Anything). + Return(&ToolResponse{ToolCallID: "tc-1", Result: []byte(`"exists"`)}, nil) + + s.env.OnActivity(s.act.PublishApprovalActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.RegisterDelayedCallback(func() { + s.env.SignalWorkflow(ApprovalSignalName, &ApprovalDecision{ + Approved: true, + Reason: "Go ahead", + }) + }, 0) + + // Second turn after approval: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Done.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: parent workflow starts child workflow for A2A agent call and receives result. +func (s *WorkflowTestSuite) TestChildWorkflowSuccess() { + req := basicRequest() + + // Session activity: called by both parent and child. + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // Parent LLM turn 1: returns an agent call. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "Let me ask the specialist.", + AgentCalls: []AgentCall{ + {TargetAgent: "specialist", Message: []byte("What is the answer?")}, + }, + }, nil).Once() + + // Child LLM turn (executes inline): terminal response. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "The answer is 42.", Terminal: true}, nil).Once() + + // Parent LLM turn 2 (after child result): terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "The specialist says the answer is 42.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: child workflow failure propagates to parent as failed result. +func (s *WorkflowTestSuite) TestChildWorkflowFailurePropagates() { + req := basicRequest() + + // Parent session succeeds. + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil).Once() + + // Parent LLM turn: returns agent call. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + AgentCalls: []AgentCall{ + {TargetAgent: "broken-agent", Message: []byte("help")}, + }, + }, nil) + + // Child session fails (causes child workflow error -> propagates to parent). + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(nil, errSessionUnavailable).Once() + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("failed", result.Status) + s.Contains(result.Reason, "child workflow failed") +} + +// Test: parallel child workflows (multiple A2A calls in one turn). +func (s *WorkflowTestSuite) TestParallelChildWorkflows() { + req := basicRequest() + + // Session activity: parent + 2 children. + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // Parent LLM turn 1: two agent calls. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + Content: "Let me consult both experts.", + AgentCalls: []AgentCall{ + {TargetAgent: "expert-a", Message: []byte("question A")}, + {TargetAgent: "expert-b", Message: []byte("question B")}, + }, + }, nil).Once() + + // Child A LLM turn: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Answer A", Terminal: true}, nil).Once() + + // Child B LLM turn: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Answer B", Terminal: true}, nil).Once() + + // Parent LLM turn 2: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Both experts agree.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Test: agent calls combined with tool calls in the same turn. +func (s *WorkflowTestSuite) TestAgentCallsWithToolCalls() { + req := basicRequest() + + // Session: parent + child. + s.env.OnActivity(s.act.SessionActivity, mock.Anything, mock.Anything). + Return(&SessionResponse{SessionID: "sess-1", Created: true}, nil) + + // Parent LLM turn 1: both tool calls and agent calls. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{ + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "get_data", Args: []byte(`{}`)}, + }, + AgentCalls: []AgentCall{ + {TargetAgent: "analyzer", Message: []byte("analyze this")}, + }, + }, nil).Once() + + // Tool execution. + s.env.OnActivity(s.act.ToolExecuteActivity, mock.Anything, mock.Anything). + Return(&ToolResponse{ToolCallID: "tc-1", Result: []byte(`"data"`)}, nil) + + // Child LLM turn: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "Analysis complete.", Terminal: true}, nil).Once() + + // Parent LLM turn 2: terminal. + s.env.OnActivity(s.act.LLMInvokeActivity, mock.Anything, mock.Anything). + Return(&LLMResponse{Content: "All done.", Terminal: true}, nil).Once() + + s.env.OnActivity(s.act.SaveTaskActivity, mock.Anything, mock.Anything).Return(nil) + s.env.OnActivity(s.act.PublishCompletionActivity, mock.Anything, mock.Anything).Return(nil) + + s.env.ExecuteWorkflow(AgentExecutionWorkflow, req) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + + var result ExecutionResult + s.NoError(s.env.GetWorkflowResult(&result)) + s.Equal("completed", result.Status) +} + +// Sentinel errors for test mocks (Temporal test suite needs concrete errors). +var ( + errLLMUnavailable = &testError{"LLM provider unavailable"} + errSessionUnavailable = &testError{"session service unavailable"} + errToolCrash = &testError{"tool executor crashed"} +) + +type testError struct{ msg string } + +func (e *testError) Error() string { return e.msg } diff --git a/go/api/adk/types.go b/go/api/adk/types.go index aee673f09..41cfb79c4 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -427,18 +427,32 @@ func (c *AgentCompressionConfig) UnmarshalJSON(data []byte) error { return nil } +// TemporalRuntimeConfig is the Temporal configuration carried in config.json. +// It is set by the CRD translator when spec.temporal is defined on the Agent. +type TemporalRuntimeConfig struct { + Enabled bool `json:"enabled"` + HostAddr string `json:"host_addr,omitempty"` + Namespace string `json:"namespace,omitempty"` + TaskQueue string `json:"task_queue,omitempty"` + NATSAddr string `json:"nats_addr,omitempty"` + WorkflowTimeout string `json:"workflow_timeout,omitempty"` // duration string, e.g. "48h" + LLMMaxAttempts int `json:"llm_max_attempts,omitempty"` + ToolMaxAttempts int `json:"tool_max_attempts,omitempty"` +} + // See `python/packages/kagent-adk/src/kagent/adk/types.py` for the python version of this type AgentConfig struct { - Model Model `json:"model"` - Description string `json:"description"` - Instruction string `json:"instruction"` - HttpTools []HttpMcpServerConfig `json:"http_tools,omitempty"` - SseTools []SseMcpServerConfig `json:"sse_tools,omitempty"` - RemoteAgents []RemoteAgentConfig `json:"remote_agents,omitempty"` - ExecuteCode *bool `json:"execute_code,omitempty"` - Stream *bool `json:"stream,omitempty"` - Memory *MemoryConfig `json:"memory,omitempty"` - ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Model Model `json:"model"` + Description string `json:"description"` + Instruction string `json:"instruction"` + HttpTools []HttpMcpServerConfig `json:"http_tools,omitempty"` + SseTools []SseMcpServerConfig `json:"sse_tools,omitempty"` + RemoteAgents []RemoteAgentConfig `json:"remote_agents,omitempty"` + ExecuteCode *bool `json:"execute_code,omitempty"` + Stream *bool `json:"stream,omitempty"` + Memory *MemoryConfig `json:"memory,omitempty"` + ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Temporal *TemporalRuntimeConfig `json:"temporal,omitempty"` } // GetStream returns the stream value or default if not set @@ -459,16 +473,17 @@ func (a *AgentConfig) GetExecuteCode() bool { func (a *AgentConfig) UnmarshalJSON(data []byte) error { var tmp struct { - Model json.RawMessage `json:"model"` - Description string `json:"description"` - Instruction string `json:"instruction"` - HttpTools []HttpMcpServerConfig `json:"http_tools,omitempty"` - SseTools []SseMcpServerConfig `json:"sse_tools,omitempty"` - RemoteAgents []RemoteAgentConfig `json:"remote_agents,omitempty"` - ExecuteCode *bool `json:"execute_code,omitempty"` - Stream *bool `json:"stream,omitempty"` - Memory json.RawMessage `json:"memory"` - ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Model json.RawMessage `json:"model"` + Description string `json:"description"` + Instruction string `json:"instruction"` + HttpTools []HttpMcpServerConfig `json:"http_tools,omitempty"` + SseTools []SseMcpServerConfig `json:"sse_tools,omitempty"` + RemoteAgents []RemoteAgentConfig `json:"remote_agents,omitempty"` + ExecuteCode *bool `json:"execute_code,omitempty"` + Stream *bool `json:"stream,omitempty"` + Memory json.RawMessage `json:"memory"` + ContextConfig *AgentContextConfig `json:"context_config,omitempty"` + Temporal *TemporalRuntimeConfig `json:"temporal,omitempty"` } if err := json.Unmarshal(data, &tmp); err != nil { return err @@ -497,6 +512,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error { a.Stream = tmp.Stream a.Memory = memory a.ContextConfig = tmp.ContextConfig + a.Temporal = tmp.Temporal return nil } diff --git a/go/api/config/crd/bases/kagent.dev_agentcronjobs.yaml b/go/api/config/crd/bases/kagent.dev_agentcronjobs.yaml new file mode 100644 index 000000000..d7defdcfb --- /dev/null +++ b/go/api/config/crd/bases/kagent.dev_agentcronjobs.yaml @@ -0,0 +1,170 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: agentcronjobs.kagent.dev +spec: + group: kagent.dev + names: + kind: AgentCronJob + listKind: AgentCronJobList + plural: agentcronjobs + singular: agentcronjob + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Cron schedule expression. + jsonPath: .spec.schedule + name: Schedule + type: string + - description: Referenced Agent CR name. + jsonPath: .spec.agentRef + name: Agent + type: string + - description: Time of the last execution. + jsonPath: .status.lastRunTime + name: LastRun + type: date + - description: Time of the next scheduled execution. + jsonPath: .status.nextRunTime + name: NextRun + type: date + - description: Result of the last execution. + jsonPath: .status.lastRunResult + name: LastResult + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: AgentCronJob is the Schema for the agentcronjobs API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AgentCronJobSpec defines the desired state of AgentCronJob. + properties: + agentRef: + description: AgentRef is the name of the Agent CR to invoke. Must + be in the same namespace. + minLength: 1 + type: string + prompt: + description: Prompt is the static user message sent to the agent on + each run. + minLength: 1 + type: string + schedule: + description: 'Schedule in standard cron format (5-field: minute hour + day month weekday).' + minLength: 1 + type: string + required: + - agentRef + - prompt + - schedule + type: object + status: + description: AgentCronJobStatus defines the observed state of AgentCronJob. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastRunMessage: + description: LastRunMessage contains error details when LastRunResult + is "Failed". + type: string + lastRunResult: + description: 'LastRunResult is the result of the most recent execution: + "Success" or "Failed".' + type: string + lastRunTime: + description: LastRunTime is the timestamp of the most recent execution. + format: date-time + type: string + lastSessionID: + description: LastSessionID is the session ID created by the most recent + execution. + type: string + nextRunTime: + description: NextRunTime is the calculated timestamp of the next execution. + format: date-time + type: string + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index 8b735e616..5a4259080 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -10213,6 +10213,40 @@ spec: minItems: 1 type: array type: object + temporal: + description: |- + Temporal configures durable workflow execution for this agent. + When enabled, agent execution runs as Temporal workflows with per-turn + activity granularity, crash recovery, and configurable retry policies. + properties: + enabled: + description: Enabled controls whether this agent uses Temporal + for execution. + type: boolean + retryPolicy: + description: RetryPolicy configures activity retry behavior. + properties: + llmMaxAttempts: + description: |- + LLMMaxAttempts is the maximum number of retry attempts for LLM activities. + Default: 5. + format: int32 + minimum: 1 + type: integer + toolMaxAttempts: + description: |- + ToolMaxAttempts is the maximum number of retry attempts for tool activities. + Default: 3. + format: int32 + minimum: 1 + type: integer + type: object + workflowTimeout: + description: |- + WorkflowTimeout is the maximum duration for a workflow execution. + Default: 3m. + type: string + type: object type: allOf: - enum: diff --git a/go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml b/go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml index 534c27b35..f23f51dab 100644 --- a/go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml +++ b/go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml @@ -176,6 +176,57 @@ spec: type: boolean timeout: type: string + ui: + description: |- + UI defines optional web UI metadata for this MCP server. + When ui.enabled is true, the server's UI is accessible via /_p/{ui.pathPrefix}/ (proxy) + and browser URL /plugins/{ui.pathPrefix} (Next.js wrapper with sidebar + iframe) + properties: + defaultPath: + description: |- + DefaultPath is the initial path to redirect to when the plugin root is loaded. + For example, "/namespaces/kagent" makes the plugin open at that path by default. + type: string + displayName: + description: |- + DisplayName is the human-readable name shown in the sidebar. + Defaults to the RemoteMCPServer name if not specified. + type: string + enabled: + default: false + description: Enabled indicates this MCP server provides a web + UI. + type: boolean + icon: + default: puzzle + description: Icon is a lucide-react icon name (e.g., "kanban", + "git-fork", "database"). + type: string + injectCSS: + description: |- + InjectCSS is custom CSS injected into proxied HTML responses to customize the plugin UI. + For example, `[data-testid="navigation-header"] { display: none !important; }` hides the nav. + type: string + pathPrefix: + description: |- + PathPrefix is the URL path segment used for routing: /_p/{pathPrefix}/ + Must be a valid URL path segment (lowercase alphanumeric + hyphens). + Defaults to the RemoteMCPServer name if not specified. + maxLength: 63 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + section: + default: PLUGINS + description: Section is the sidebar section where this plugin + appears. + enum: + - OVERVIEW + - AGENTS + - RESOURCES + - ADMIN + - PLUGINS + type: string + type: object url: minLength: 1 type: string diff --git a/go/api/config/crd/bases/kagent.dev_workflowruns.yaml b/go/api/config/crd/bases/kagent.dev_workflowruns.yaml new file mode 100644 index 000000000..e9cec518c --- /dev/null +++ b/go/api/config/crd/bases/kagent.dev_workflowruns.yaml @@ -0,0 +1,461 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: workflowruns.kagent.dev +spec: + group: kagent.dev + names: + kind: WorkflowRun + listKind: WorkflowRunList + plural: workflowruns + singular: workflowrun + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.workflowTemplateRef + name: Template + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: WorkflowRun is the Schema for the workflowruns API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WorkflowRunSpec defines the desired state of a WorkflowRun. + properties: + params: + description: Params provides values for template parameters. + items: + description: Param provides a value for a template parameter. + properties: + name: + description: Name of the parameter. + type: string + value: + description: Value of the parameter. + type: string + required: + - name + - value + type: object + type: array + ttlSecondsAfterFinished: + description: TTLSecondsAfterFinished controls automatic deletion after + completion. + format: int32 + type: integer + workflowTemplateRef: + description: WorkflowTemplateRef is the name of the WorkflowTemplate. + type: string + required: + - workflowTemplateRef + type: object + status: + description: WorkflowRunStatus defines the observed state of a WorkflowRun. + properties: + completionTime: + description: CompletionTime is when the workflow finished. + format: date-time + type: string + conditions: + description: Conditions represent the latest available observations. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the most recent generation observed. + format: int64 + type: integer + phase: + description: 'Phase is a derived summary: Pending, Running, Succeeded, + Failed, Cancelled.' + enum: + - Pending + - Running + - Succeeded + - Failed + - Cancelled + type: string + resolvedSpec: + description: ResolvedSpec is the snapshot of the template at run creation. + properties: + defaults: + description: Defaults for step policies when not specified per-step. + properties: + retry: + description: Retry default policy. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types that + should not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout default policy. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time including + retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a single + attempt. + type: string + type: object + type: object + description: + description: Description of the workflow. + type: string + params: + description: Params declares input parameters. + items: + description: ParamSpec declares an input parameter for a workflow + template. + properties: + default: + description: Default value for the parameter. + type: string + description: + description: Description of the parameter. + type: string + enum: + description: Enum restricts the parameter to a set of allowed + values. + items: + type: string + type: array + name: + description: Name is the parameter name. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + type: + allOf: + - enum: + - string + - number + - boolean + - enum: + - string + - number + - boolean + default: string + description: Type is the parameter type. + type: string + required: + - name + type: object + type: array + retention: + description: Retention controls run history cleanup. + properties: + failedRunsHistoryLimit: + default: 5 + description: FailedRunsHistoryLimit is the max number of failed + runs to keep. + format: int32 + type: integer + successfulRunsHistoryLimit: + default: 10 + description: SuccessfulRunsHistoryLimit is the max number + of successful runs to keep. + format: int32 + type: integer + type: object + steps: + description: Steps defines the workflow DAG. + items: + description: StepSpec defines a single step in the workflow + DAG. + properties: + action: + description: Action is the registered activity name (for + type=action). + type: string + agentRef: + description: AgentRef is the kagent Agent name (for type=agent). + type: string + dependsOn: + description: DependsOn lists step names that must complete + before this step runs. + items: + type: string + type: array + name: + description: Name uniquely identifies this step within the + workflow. + pattern: ^[a-z][a-z0-9-]*$ + type: string + onFailure: + default: stop + description: OnFailure determines behavior when this step + fails. + enum: + - stop + - continue + type: string + output: + description: Output configures how step results are stored + in context. + properties: + as: + description: |- + As stores the full step result at context.. + Defaults to step name if omitted. + type: string + keys: + additionalProperties: + type: string + description: Keys maps selected output fields to top-level + context keys. + type: object + type: object + policy: + description: Policy overrides workflow-level defaults for + this step. + properties: + retry: + description: Retry configures retry behavior. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry + delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of + attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry + delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types + that should not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout configures timeout behavior. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time + including retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a + single attempt. + type: string + type: object + type: object + prompt: + description: |- + Prompt is a template rendered before agent invocation (for type=agent). + Supports expression interpolation for params and context values. + type: string + type: + allOf: + - enum: + - action + - agent + - enum: + - action + - agent + description: Type is the step execution mode. + type: string + with: + additionalProperties: + type: string + description: |- + With provides input key-value pairs for the step. + Values support expression interpolation. + type: object + required: + - name + - type + type: object + maxItems: 200 + minItems: 1 + type: array + required: + - steps + type: object + startTime: + description: StartTime is when the Temporal workflow started. + format: date-time + type: string + steps: + description: Steps tracks per-step execution status. + items: + description: StepStatus tracks the execution status of a single + step. + properties: + completionTime: + description: CompletionTime is when the step finished executing. + format: date-time + type: string + message: + description: Message provides additional detail about the step + status. + type: string + name: + description: Name of the step. + type: string + phase: + description: Phase is the current execution phase. + enum: + - Pending + - Running + - Succeeded + - Failed + - Skipped + type: string + retries: + description: Retries is the number of retry attempts made. + format: int32 + type: integer + sessionID: + description: SessionID is the child workflow session ID for + agent steps. + type: string + startTime: + description: StartTime is when the step started executing. + format: date-time + type: string + required: + - name + - phase + type: object + type: array + templateGeneration: + description: TemplateGeneration tracks which generation of the template + was used. + format: int64 + type: integer + temporalWorkflowID: + description: TemporalWorkflowID is the Temporal workflow execution + ID. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/go/api/config/crd/bases/kagent.dev_workflowtemplates.yaml b/go/api/config/crd/bases/kagent.dev_workflowtemplates.yaml new file mode 100644 index 000000000..40518e807 --- /dev/null +++ b/go/api/config/crd/bases/kagent.dev_workflowtemplates.yaml @@ -0,0 +1,361 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: workflowtemplates.kagent.dev +spec: + group: kagent.dev + names: + kind: WorkflowTemplate + listKind: WorkflowTemplateList + plural: workflowtemplates + singular: workflowtemplate + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.stepCount + name: Steps + type: integer + - jsonPath: .status.validated + name: Validated + type: boolean + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: WorkflowTemplate is the Schema for the workflowtemplates API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WorkflowTemplateSpec defines the desired state of a WorkflowTemplate. + properties: + defaults: + description: Defaults for step policies when not specified per-step. + properties: + retry: + description: Retry default policy. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types that should + not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout default policy. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time including + retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a single attempt. + type: string + type: object + type: object + description: + description: Description of the workflow. + type: string + params: + description: Params declares input parameters. + items: + description: ParamSpec declares an input parameter for a workflow + template. + properties: + default: + description: Default value for the parameter. + type: string + description: + description: Description of the parameter. + type: string + enum: + description: Enum restricts the parameter to a set of allowed + values. + items: + type: string + type: array + name: + description: Name is the parameter name. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + type: + allOf: + - enum: + - string + - number + - boolean + - enum: + - string + - number + - boolean + default: string + description: Type is the parameter type. + type: string + required: + - name + type: object + type: array + retention: + description: Retention controls run history cleanup. + properties: + failedRunsHistoryLimit: + default: 5 + description: FailedRunsHistoryLimit is the max number of failed + runs to keep. + format: int32 + type: integer + successfulRunsHistoryLimit: + default: 10 + description: SuccessfulRunsHistoryLimit is the max number of successful + runs to keep. + format: int32 + type: integer + type: object + steps: + description: Steps defines the workflow DAG. + items: + description: StepSpec defines a single step in the workflow DAG. + properties: + action: + description: Action is the registered activity name (for type=action). + type: string + agentRef: + description: AgentRef is the kagent Agent name (for type=agent). + type: string + dependsOn: + description: DependsOn lists step names that must complete before + this step runs. + items: + type: string + type: array + name: + description: Name uniquely identifies this step within the workflow. + pattern: ^[a-z][a-z0-9-]*$ + type: string + onFailure: + default: stop + description: OnFailure determines behavior when this step fails. + enum: + - stop + - continue + type: string + output: + description: Output configures how step results are stored in + context. + properties: + as: + description: |- + As stores the full step result at context.. + Defaults to step name if omitted. + type: string + keys: + additionalProperties: + type: string + description: Keys maps selected output fields to top-level + context keys. + type: object + type: object + policy: + description: Policy overrides workflow-level defaults for this + step. + properties: + retry: + description: Retry configures retry behavior. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types that + should not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout configures timeout behavior. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time including + retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a single + attempt. + type: string + type: object + type: object + prompt: + description: |- + Prompt is a template rendered before agent invocation (for type=agent). + Supports expression interpolation for params and context values. + type: string + type: + allOf: + - enum: + - action + - agent + - enum: + - action + - agent + description: Type is the step execution mode. + type: string + with: + additionalProperties: + type: string + description: |- + With provides input key-value pairs for the step. + Values support expression interpolation. + type: object + required: + - name + - type + type: object + maxItems: 200 + minItems: 1 + type: array + required: + - steps + type: object + status: + description: WorkflowTemplateStatus defines the observed state of a WorkflowTemplate. + properties: + conditions: + description: Conditions represent the latest available observations. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the most recent generation observed. + format: int64 + type: integer + stepCount: + description: StepCount is the number of steps in the template. + format: int32 + type: integer + validated: + description: Validated indicates the template passed DAG and reference + validation. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/go/api/database/client.go b/go/api/database/client.go index 0300dbc0c..6564bf62d 100644 --- a/go/api/database/client.go +++ b/go/api/database/client.go @@ -72,6 +72,12 @@ type Client interface { StoreCrewAIFlowState(state *CrewAIFlowState) error GetCrewAIFlowState(userID, threadID string) (*CrewAIFlowState, error) + // Plugin methods + StorePlugin(plugin *Plugin) (*Plugin, error) + DeletePlugin(name string) error + GetPluginByPathPrefix(pathPrefix string) (*Plugin, error) + ListPlugins() ([]Plugin, error) + // Agent memory (vector search) methods StoreAgentMemory(memory *Memory) error StoreAgentMemories(memories []*Memory) error diff --git a/go/api/database/models.go b/go/api/database/models.go index b7cfd80b2..a23142c9a 100644 --- a/go/api/database/models.go +++ b/go/api/database/models.go @@ -234,6 +234,31 @@ type AgentMemorySearchResult struct { Score float64 `gorm:"column:score" json:"score"` } +// Plugin represents an MCP server that provides a web UI. +// Populated by the controller from RemoteMCPServer CRDs with ui.enabled=true. +type Plugin struct { + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + // Name is the RemoteMCPServer ref (namespace/name format) + Name string `gorm:"primaryKey;not null" json:"name"` + // PathPrefix is the URL routing segment + PathPrefix string `gorm:"uniqueIndex;not null" json:"path_prefix"` + // DisplayName for sidebar + DisplayName string `json:"display_name"` + // Icon is the lucide-react icon name + Icon string `json:"icon"` + // Section is the sidebar section + Section string `json:"section"` + // UpstreamURL is the base URL to proxy to (derived from spec.url) + UpstreamURL string `json:"upstream_url"` + // DefaultPath is the initial path to redirect to when the plugin root is loaded (e.g. "/namespaces/kagent") + DefaultPath string `json:"default_path,omitempty"` + // InjectCSS is custom CSS injected into proxied HTML responses to customize the plugin UI + InjectCSS string `json:"inject_css,omitempty"` +} + // TableName methods to match Python table names func (Agent) TableName() string { return "agent" } func (Event) TableName() string { return "event" } @@ -248,3 +273,4 @@ func (LangGraphCheckpointWrite) TableName() string { return "lg_checkpoint_write func (CrewAIAgentMemory) TableName() string { return "crewai_agent_memory" } func (CrewAIFlowState) TableName() string { return "crewai_flow_state" } func (Memory) TableName() string { return "memory" } +func (Plugin) TableName() string { return "plugin" } diff --git a/go/api/httpapi/types.go b/go/api/httpapi/types.go index 8679cc909..c8eb8221f 100644 --- a/go/api/httpapi/types.go +++ b/go/api/httpapi/types.go @@ -193,3 +193,52 @@ type SessionRunsResponse struct { type SessionRunsData struct { Runs []any `json:"runs"` } + +// Dashboard types + +type DashboardStatsResponse struct { + Counts DashboardCounts `json:"counts"` + RecentRuns []RecentRun `json:"recentRuns"` + RecentEvents []RecentEvent `json:"recentEvents"` +} + +type DashboardCounts struct { + Agents int `json:"agents"` + Workflows int `json:"workflows"` + CronJobs int `json:"cronJobs"` + Models int `json:"models"` + Tools int `json:"tools"` + MCPServers int `json:"mcpServers"` + GitRepos int `json:"gitRepos"` +} + +type RecentRun struct { + SessionID string `json:"sessionId"` + SessionName string `json:"sessionName"` + AgentName string `json:"agentName"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type RecentEvent struct { + ID uint `json:"id"` + SessionID string `json:"sessionId"` + Summary string `json:"summary"` + CreatedAt string `json:"createdAt"` +} + +// Workflow types + +// CreateWorkflowRunRequest represents a request to create a workflow run. +type CreateWorkflowRunRequest struct { + // Name for the WorkflowRun resource. + Name string `json:"name"` + // Namespace for the WorkflowRun resource. Defaults to the resource namespace. + Namespace string `json:"namespace,omitempty"` + // WorkflowTemplateRef is the name of the WorkflowTemplate. + WorkflowTemplateRef string `json:"workflowTemplateRef"` + // Params provides values for template parameters. + Params []v1alpha2.Param `json:"params,omitempty"` + // TTLSecondsAfterFinished controls automatic deletion after completion. + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` +} diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 81c68bdf6..df833818d 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -67,6 +67,12 @@ type AgentSpec struct { // See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-routing // +optional AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"` + + // Temporal configures durable workflow execution for this agent. + // When enabled, agent execution runs as Temporal workflows with per-turn + // activity granularity, crash recovery, and configurable retry policies. + // +optional + Temporal *TemporalSpec `json:"temporal,omitempty"` } // +kubebuilder:validation:AtLeastOneOf=refs,gitRefs @@ -335,6 +341,37 @@ type ServiceAccountConfig struct { Annotations map[string]string `json:"annotations,omitempty"` } +// TemporalSpec configures Temporal-based durable workflow execution for an agent. +type TemporalSpec struct { + // Enabled controls whether this agent uses Temporal for execution. + // +optional + Enabled bool `json:"enabled,omitempty"` + + // WorkflowTimeout is the maximum duration for a workflow execution. + // Default: 3m. + // +optional + WorkflowTimeout *metav1.Duration `json:"workflowTimeout,omitempty"` + + // RetryPolicy configures activity retry behavior. + // +optional + RetryPolicy *TemporalRetryPolicy `json:"retryPolicy,omitempty"` +} + +// TemporalRetryPolicy configures per-activity retry behavior for Temporal workflows. +type TemporalRetryPolicy struct { + // LLMMaxAttempts is the maximum number of retry attempts for LLM activities. + // Default: 5. + // +optional + // +kubebuilder:validation:Minimum=1 + LLMMaxAttempts *int32 `json:"llmMaxAttempts,omitempty"` + + // ToolMaxAttempts is the maximum number of retry attempts for tool activities. + // Default: 3. + // +optional + // +kubebuilder:validation:Minimum=1 + ToolMaxAttempts *int32 `json:"toolMaxAttempts,omitempty"` +} + // ToolProviderType represents the tool provider type // +kubebuilder:validation:Enum=McpServer;Agent type ToolProviderType string diff --git a/go/api/v1alpha2/agentcronjob_types.go b/go/api/v1alpha2/agentcronjob_types.go new file mode 100644 index 000000000..32112636d --- /dev/null +++ b/go/api/v1alpha2/agentcronjob_types.go @@ -0,0 +1,98 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + AgentCronJobConditionTypeAccepted = "Accepted" + AgentCronJobConditionTypeReady = "Ready" +) + +// AgentCronJobSpec defines the desired state of AgentCronJob. +type AgentCronJobSpec struct { + // Schedule in standard cron format (5-field: minute hour day month weekday). + // +kubebuilder:validation:MinLength=1 + Schedule string `json:"schedule"` + + // Prompt is the static user message sent to the agent on each run. + // +kubebuilder:validation:MinLength=1 + Prompt string `json:"prompt"` + + // AgentRef is the name of the Agent CR to invoke. Must be in the same namespace. + // +kubebuilder:validation:MinLength=1 + AgentRef string `json:"agentRef"` +} + +// AgentCronJobStatus defines the observed state of AgentCronJob. +type AgentCronJobStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // LastRunTime is the timestamp of the most recent execution. + // +optional + LastRunTime *metav1.Time `json:"lastRunTime,omitempty"` + + // NextRunTime is the calculated timestamp of the next execution. + // +optional + NextRunTime *metav1.Time `json:"nextRunTime,omitempty"` + + // LastRunResult is the result of the most recent execution: "Success" or "Failed". + // +optional + LastRunResult string `json:"lastRunResult,omitempty"` + + // LastRunMessage contains error details when LastRunResult is "Failed". + // +optional + LastRunMessage string `json:"lastRunMessage,omitempty"` + + // LastSessionID is the session ID created by the most recent execution. + // +optional + LastSessionID string `json:"lastSessionID,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Schedule",type="string",JSONPath=".spec.schedule",description="Cron schedule expression." +// +kubebuilder:printcolumn:name="Agent",type="string",JSONPath=".spec.agentRef",description="Referenced Agent CR name." +// +kubebuilder:printcolumn:name="LastRun",type="date",JSONPath=".status.lastRunTime",description="Time of the last execution." +// +kubebuilder:printcolumn:name="NextRun",type="date",JSONPath=".status.nextRunTime",description="Time of the next scheduled execution." +// +kubebuilder:printcolumn:name="LastResult",type="string",JSONPath=".status.lastRunResult",description="Result of the last execution." +// +kubebuilder:storageversion + +// AgentCronJob is the Schema for the agentcronjobs API. +type AgentCronJob struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AgentCronJobSpec `json:"spec,omitempty"` + Status AgentCronJobStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AgentCronJobList contains a list of AgentCronJob. +type AgentCronJobList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AgentCronJob `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AgentCronJob{}, &AgentCronJobList{}) +} diff --git a/go/api/v1alpha2/remotemcpserver_types.go b/go/api/v1alpha2/remotemcpserver_types.go index f8a355894..48e9b54aa 100644 --- a/go/api/v1alpha2/remotemcpserver_types.go +++ b/go/api/v1alpha2/remotemcpserver_types.go @@ -35,6 +35,48 @@ const ( RemoteMCPServerProtocolStreamableHttp RemoteMCPServerProtocol = "STREAMABLE_HTTP" ) +// PluginUISpec defines optional UI metadata for MCP servers that provide a web interface. +type PluginUISpec struct { + // Enabled indicates this MCP server provides a web UI. + // +optional + // +kubebuilder:default=false + Enabled bool `json:"enabled,omitempty"` + + // PathPrefix is the URL path segment used for routing: /_p/{pathPrefix}/ + // Must be a valid URL path segment (lowercase alphanumeric + hyphens). + // Defaults to the RemoteMCPServer name if not specified. + // +optional + // +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*[a-z0-9]$` + // +kubebuilder:validation:MaxLength=63 + PathPrefix string `json:"pathPrefix,omitempty"` + + // DisplayName is the human-readable name shown in the sidebar. + // Defaults to the RemoteMCPServer name if not specified. + // +optional + DisplayName string `json:"displayName,omitempty"` + + // Icon is a lucide-react icon name (e.g., "kanban", "git-fork", "database"). + // +optional + // +kubebuilder:default="puzzle" + Icon string `json:"icon,omitempty"` + + // Section is the sidebar section where this plugin appears. + // +optional + // +kubebuilder:default="PLUGINS" + // +kubebuilder:validation:Enum=OVERVIEW;AGENTS;RESOURCES;ADMIN;PLUGINS + Section string `json:"section,omitempty"` + + // DefaultPath is the initial path to redirect to when the plugin root is loaded. + // For example, "/namespaces/kagent" makes the plugin open at that path by default. + // +optional + DefaultPath string `json:"defaultPath,omitempty"` + + // InjectCSS is custom CSS injected into proxied HTML responses to customize the plugin UI. + // For example, `[data-testid="navigation-header"] { display: none !important; }` hides the nav. + // +optional + InjectCSS string `json:"injectCSS,omitempty"` +} + // RemoteMCPServerSpec defines the desired state of RemoteMCPServer. type RemoteMCPServerSpec struct { Description string `json:"description"` @@ -59,6 +101,12 @@ type RemoteMCPServerSpec struct { // See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-routing // +optional AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"` + + // UI defines optional web UI metadata for this MCP server. + // When ui.enabled is true, the server's UI is accessible via /_p/{ui.pathPrefix}/ (proxy) + // and browser URL /plugins/{ui.pathPrefix} (Next.js wrapper with sidebar + iframe) + // +optional + UI *PluginUISpec `json:"ui,omitempty"` } var _ sql.Scanner = (*RemoteMCPServerSpec)(nil) diff --git a/go/api/v1alpha2/workflow_types.go b/go/api/v1alpha2/workflow_types.go new file mode 100644 index 000000000..42d5df6a1 --- /dev/null +++ b/go/api/v1alpha2/workflow_types.go @@ -0,0 +1,430 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StepType represents the step execution mode. +// +kubebuilder:validation:Enum=action;agent +type StepType string + +const ( + StepTypeAction StepType = "action" + StepTypeAgent StepType = "agent" +) + +// ParamType represents the parameter type. +// +kubebuilder:validation:Enum=string;number;boolean +type ParamType string + +const ( + ParamTypeString ParamType = "string" + ParamTypeNumber ParamType = "number" + ParamTypeBoolean ParamType = "boolean" +) + +// StepPhase represents the execution phase of a step. +// +kubebuilder:validation:Enum=Pending;Running;Succeeded;Failed;Skipped +type StepPhase string + +const ( + StepPhasePending StepPhase = "Pending" + StepPhaseRunning StepPhase = "Running" + StepPhaseSucceeded StepPhase = "Succeeded" + StepPhaseFailed StepPhase = "Failed" + StepPhaseSkipped StepPhase = "Skipped" +) + +// WorkflowRunPhase represents the overall phase of a workflow run. +const ( + WorkflowRunPhasePending = "Pending" + WorkflowRunPhaseRunning = "Running" + WorkflowRunPhaseSucceeded = "Succeeded" + WorkflowRunPhaseFailed = "Failed" + WorkflowRunPhaseCancelled = "Cancelled" +) + +// Condition types for WorkflowTemplate and WorkflowRun. +const ( + WorkflowTemplateConditionAccepted = "Accepted" + + WorkflowRunConditionAccepted = "Accepted" + WorkflowRunConditionRunning = "Running" + WorkflowRunConditionSucceeded = "Succeeded" +) + +// Finalizer for WorkflowRun temporal cleanup. +const WorkflowRunFinalizer = "kagent.dev/temporal-cleanup" + +// ParamSpec declares an input parameter for a workflow template. +type ParamSpec struct { + // Name is the parameter name. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[a-zA-Z_][a-zA-Z0-9_]*$` + Name string `json:"name"` + + // Description of the parameter. + // +optional + Description string `json:"description,omitempty"` + + // Type is the parameter type. + // +kubebuilder:validation:Enum=string;number;boolean + // +kubebuilder:default=string + // +optional + Type ParamType `json:"type,omitempty"` + + // Default value for the parameter. + // +optional + Default *string `json:"default,omitempty"` + + // Enum restricts the parameter to a set of allowed values. + // +optional + Enum []string `json:"enum,omitempty"` +} + +// Param provides a value for a template parameter. +type Param struct { + // Name of the parameter. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Value of the parameter. + // +kubebuilder:validation:Required + Value string `json:"value"` +} + +// StepOutput configures how step results are stored in workflow context. +type StepOutput struct { + // As stores the full step result at context.. + // Defaults to step name if omitted. + // +optional + As string `json:"as,omitempty"` + + // Keys maps selected output fields to top-level context keys. + // +optional + Keys map[string]string `json:"keys,omitempty"` +} + +// StepPolicy overrides workflow-level defaults for a step. +type StepPolicy struct { + // Retry configures retry behavior. + // +optional + Retry *WorkflowRetryPolicy `json:"retry,omitempty"` + + // Timeout configures timeout behavior. + // +optional + Timeout *WorkflowTimeoutPolicy `json:"timeout,omitempty"` +} + +// WorkflowRetryPolicy maps directly to Temporal's RetryPolicy. +type WorkflowRetryPolicy struct { + // MaxAttempts is the maximum number of attempts. + // +kubebuilder:default=3 + // +optional + MaxAttempts int32 `json:"maxAttempts,omitempty"` + + // InitialInterval is the initial retry delay. + // +kubebuilder:default="1s" + // +optional + InitialInterval metav1.Duration `json:"initialInterval,omitempty"` + + // MaximumInterval is the maximum retry delay. + // +kubebuilder:default="60s" + // +optional + MaximumInterval metav1.Duration `json:"maximumInterval,omitempty"` + + // BackoffCoefficient is the multiplier for retry delays. + // Serialized as string to avoid float precision issues across languages. + // +kubebuilder:default="2.0" + // +optional + BackoffCoefficient string `json:"backoffCoefficient,omitempty"` + + // NonRetryableErrors lists error types that should not be retried. + // +optional + NonRetryableErrors []string `json:"nonRetryableErrors,omitempty"` +} + +// WorkflowTimeoutPolicy maps to Temporal activity timeout fields. +type WorkflowTimeoutPolicy struct { + // StartToClose is the max time for a single attempt. + // +kubebuilder:default="5m" + // +optional + StartToClose metav1.Duration `json:"startToClose,omitempty"` + + // ScheduleToClose is the max total time including retries. + // +optional + ScheduleToClose *metav1.Duration `json:"scheduleToClose,omitempty"` + + // Heartbeat is the max time between heartbeats. + // +optional + Heartbeat *metav1.Duration `json:"heartbeat,omitempty"` +} + +// StepPolicyDefaults defines default policies applied to steps. +type StepPolicyDefaults struct { + // Retry default policy. + // +optional + Retry *WorkflowRetryPolicy `json:"retry,omitempty"` + + // Timeout default policy. + // +optional + Timeout *WorkflowTimeoutPolicy `json:"timeout,omitempty"` +} + +// RetentionPolicy controls run history cleanup. +type RetentionPolicy struct { + // SuccessfulRunsHistoryLimit is the max number of successful runs to keep. + // +kubebuilder:default=10 + // +optional + SuccessfulRunsHistoryLimit *int32 `json:"successfulRunsHistoryLimit,omitempty"` + + // FailedRunsHistoryLimit is the max number of failed runs to keep. + // +kubebuilder:default=5 + // +optional + FailedRunsHistoryLimit *int32 `json:"failedRunsHistoryLimit,omitempty"` +} + +// StepSpec defines a single step in the workflow DAG. +type StepSpec struct { + // Name uniquely identifies this step within the workflow. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[a-z][a-z0-9-]*$` + Name string `json:"name"` + + // Type is the step execution mode. + // +kubebuilder:validation:Enum=action;agent + Type StepType `json:"type"` + + // Action is the registered activity name (for type=action). + // +optional + Action string `json:"action,omitempty"` + + // AgentRef is the kagent Agent name (for type=agent). + // +optional + AgentRef string `json:"agentRef,omitempty"` + + // Prompt is a template rendered before agent invocation (for type=agent). + // Supports expression interpolation for params and context values. + // +optional + Prompt string `json:"prompt,omitempty"` + + // With provides input key-value pairs for the step. + // Values support expression interpolation. + // +optional + With map[string]string `json:"with,omitempty"` + + // DependsOn lists step names that must complete before this step runs. + // +optional + DependsOn []string `json:"dependsOn,omitempty"` + + // Output configures how step results are stored in context. + // +optional + Output *StepOutput `json:"output,omitempty"` + + // Policy overrides workflow-level defaults for this step. + // +optional + Policy *StepPolicy `json:"policy,omitempty"` + + // OnFailure determines behavior when this step fails. + // +kubebuilder:validation:Enum=stop;continue + // +kubebuilder:default=stop + // +optional + OnFailure string `json:"onFailure,omitempty"` +} + +// StepStatus tracks the execution status of a single step. +type StepStatus struct { + // Name of the step. + Name string `json:"name"` + + // Phase is the current execution phase. + Phase StepPhase `json:"phase"` + + // StartTime is when the step started executing. + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + + // CompletionTime is when the step finished executing. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + + // Message provides additional detail about the step status. + // +optional + Message string `json:"message,omitempty"` + + // Retries is the number of retry attempts made. + // +optional + Retries int32 `json:"retries,omitempty"` + + // SessionID is the child workflow session ID for agent steps. + // +optional + SessionID string `json:"sessionID,omitempty"` +} + +// --- WorkflowTemplate --- + +// WorkflowTemplateSpec defines the desired state of a WorkflowTemplate. +type WorkflowTemplateSpec struct { + // Description of the workflow. + // +optional + Description string `json:"description,omitempty"` + + // Params declares input parameters. + // +optional + Params []ParamSpec `json:"params,omitempty"` + + // Steps defines the workflow DAG. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=200 + Steps []StepSpec `json:"steps"` + + // Defaults for step policies when not specified per-step. + // +optional + Defaults *StepPolicyDefaults `json:"defaults,omitempty"` + + // Retention controls run history cleanup. + // +optional + Retention *RetentionPolicy `json:"retention,omitempty"` +} + +// WorkflowTemplateStatus defines the observed state of a WorkflowTemplate. +type WorkflowTemplateStatus struct { + // ObservedGeneration is the most recent generation observed. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions represent the latest available observations. + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // StepCount is the number of steps in the template. + StepCount int32 `json:"stepCount,omitempty"` + + // Validated indicates the template passed DAG and reference validation. + Validated bool `json:"validated,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Steps",type=integer,JSONPath=`.status.stepCount` +// +kubebuilder:printcolumn:name="Validated",type=boolean,JSONPath=`.status.validated` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// WorkflowTemplate is the Schema for the workflowtemplates API. +type WorkflowTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WorkflowTemplateSpec `json:"spec,omitempty"` + Status WorkflowTemplateStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// WorkflowTemplateList contains a list of WorkflowTemplate. +type WorkflowTemplateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []WorkflowTemplate `json:"items"` +} + +// --- WorkflowRun --- + +// WorkflowRunSpec defines the desired state of a WorkflowRun. +type WorkflowRunSpec struct { + // WorkflowTemplateRef is the name of the WorkflowTemplate. + // +kubebuilder:validation:Required + WorkflowTemplateRef string `json:"workflowTemplateRef"` + + // Params provides values for template parameters. + // +optional + Params []Param `json:"params,omitempty"` + + // TTLSecondsAfterFinished controls automatic deletion after completion. + // +optional + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` +} + +// WorkflowRunStatus defines the observed state of a WorkflowRun. +type WorkflowRunStatus struct { + // ObservedGeneration is the most recent generation observed. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions represent the latest available observations. + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Phase is a derived summary: Pending, Running, Succeeded, Failed, Cancelled. + // +kubebuilder:validation:Enum=Pending;Running;Succeeded;Failed;Cancelled + // +optional + Phase string `json:"phase,omitempty"` + + // ResolvedSpec is the snapshot of the template at run creation. + // +optional + ResolvedSpec *WorkflowTemplateSpec `json:"resolvedSpec,omitempty"` + + // TemplateGeneration tracks which generation of the template was used. + TemplateGeneration int64 `json:"templateGeneration,omitempty"` + + // TemporalWorkflowID is the Temporal workflow execution ID. + // +optional + TemporalWorkflowID string `json:"temporalWorkflowID,omitempty"` + + // StartTime is when the Temporal workflow started. + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + + // CompletionTime is when the workflow finished. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + + // Steps tracks per-step execution status. + // +optional + Steps []StepStatus `json:"steps,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Template",type=string,JSONPath=`.spec.workflowTemplateRef` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// WorkflowRun is the Schema for the workflowruns API. +type WorkflowRun struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WorkflowRunSpec `json:"spec,omitempty"` + Status WorkflowRunStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// WorkflowRunList contains a list of WorkflowRun. +type WorkflowRunList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []WorkflowRun `json:"items"` +} + +func init() { + SchemeBuilder.Register( + &WorkflowTemplate{}, &WorkflowTemplateList{}, + &WorkflowRun{}, &WorkflowRunList{}, + ) +} diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 52b0309ec..a138d7c01 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -75,6 +75,110 @@ func (in *Agent) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentCronJob) DeepCopyInto(out *AgentCronJob) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentCronJob. +func (in *AgentCronJob) DeepCopy() *AgentCronJob { + if in == nil { + return nil + } + out := new(AgentCronJob) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentCronJob) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentCronJobList) DeepCopyInto(out *AgentCronJobList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AgentCronJob, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentCronJobList. +func (in *AgentCronJobList) DeepCopy() *AgentCronJobList { + if in == nil { + return nil + } + out := new(AgentCronJobList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentCronJobList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentCronJobSpec) DeepCopyInto(out *AgentCronJobSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentCronJobSpec. +func (in *AgentCronJobSpec) DeepCopy() *AgentCronJobSpec { + if in == nil { + return nil + } + out := new(AgentCronJobSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentCronJobStatus) DeepCopyInto(out *AgentCronJobStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LastRunTime != nil { + in, out := &in.LastRunTime, &out.LastRunTime + *out = (*in).DeepCopy() + } + if in.NextRunTime != nil { + in, out := &in.NextRunTime, &out.NextRunTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentCronJobStatus. +func (in *AgentCronJobStatus) DeepCopy() *AgentCronJobStatus { + if in == nil { + return nil + } + out := new(AgentCronJobStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentList) DeepCopyInto(out *AgentList) { *out = *in @@ -170,6 +274,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(AllowedNamespaces) (*in).DeepCopyInto(*out) } + if in.Temporal != nil { + in, out := &in.Temporal, &out.Temporal + *out = new(TemporalSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -945,6 +1054,61 @@ func (in *OpenAIConfig) DeepCopy() *OpenAIConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Param) DeepCopyInto(out *Param) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Param. +func (in *Param) DeepCopy() *Param { + if in == nil { + return nil + } + out := new(Param) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ParamSpec) DeepCopyInto(out *ParamSpec) { + *out = *in + if in.Default != nil { + in, out := &in.Default, &out.Default + *out = new(string) + **out = **in + } + if in.Enum != nil { + in, out := &in.Enum, &out.Enum + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ParamSpec. +func (in *ParamSpec) DeepCopy() *ParamSpec { + if in == nil { + return nil + } + out := new(ParamSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PluginUISpec) DeepCopyInto(out *PluginUISpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PluginUISpec. +func (in *PluginUISpec) DeepCopy() *PluginUISpec { + if in == nil { + return nil + } + out := new(PluginUISpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PromptSource) DeepCopyInto(out *PromptSource) { *out = *in @@ -1070,6 +1234,11 @@ func (in *RemoteMCPServerSpec) DeepCopyInto(out *RemoteMCPServerSpec) { *out = new(AllowedNamespaces) (*in).DeepCopyInto(*out) } + if in.UI != nil { + in, out := &in.UI, &out.UI + *out = new(PluginUISpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteMCPServerSpec. @@ -1115,6 +1284,31 @@ func (in *RemoteMCPServerStatus) DeepCopy() *RemoteMCPServerStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RetentionPolicy) DeepCopyInto(out *RetentionPolicy) { + *out = *in + if in.SuccessfulRunsHistoryLimit != nil { + in, out := &in.SuccessfulRunsHistoryLimit, &out.SuccessfulRunsHistoryLimit + *out = new(int32) + **out = **in + } + if in.FailedRunsHistoryLimit != nil { + in, out := &in.FailedRunsHistoryLimit, &out.FailedRunsHistoryLimit + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RetentionPolicy. +func (in *RetentionPolicy) DeepCopy() *RetentionPolicy { + if in == nil { + return nil + } + out := new(RetentionPolicy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretReference) DeepCopyInto(out *SecretReference) { *out = *in @@ -1293,6 +1487,138 @@ func (in *SkillForAgent) DeepCopy() *SkillForAgent { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepOutput) DeepCopyInto(out *StepOutput) { + *out = *in + if in.Keys != nil { + in, out := &in.Keys, &out.Keys + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepOutput. +func (in *StepOutput) DeepCopy() *StepOutput { + if in == nil { + return nil + } + out := new(StepOutput) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepPolicy) DeepCopyInto(out *StepPolicy) { + *out = *in + if in.Retry != nil { + in, out := &in.Retry, &out.Retry + *out = new(WorkflowRetryPolicy) + (*in).DeepCopyInto(*out) + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(WorkflowTimeoutPolicy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepPolicy. +func (in *StepPolicy) DeepCopy() *StepPolicy { + if in == nil { + return nil + } + out := new(StepPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepPolicyDefaults) DeepCopyInto(out *StepPolicyDefaults) { + *out = *in + if in.Retry != nil { + in, out := &in.Retry, &out.Retry + *out = new(WorkflowRetryPolicy) + (*in).DeepCopyInto(*out) + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(WorkflowTimeoutPolicy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepPolicyDefaults. +func (in *StepPolicyDefaults) DeepCopy() *StepPolicyDefaults { + if in == nil { + return nil + } + out := new(StepPolicyDefaults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepSpec) DeepCopyInto(out *StepSpec) { + *out = *in + if in.With != nil { + in, out := &in.With, &out.With + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.DependsOn != nil { + in, out := &in.DependsOn, &out.DependsOn + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Output != nil { + in, out := &in.Output, &out.Output + *out = new(StepOutput) + (*in).DeepCopyInto(*out) + } + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(StepPolicy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepSpec. +func (in *StepSpec) DeepCopy() *StepSpec { + if in == nil { + return nil + } + out := new(StepSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepStatus) DeepCopyInto(out *StepStatus) { + *out = *in + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepStatus. +func (in *StepStatus) DeepCopy() *StepStatus { + if in == nil { + return nil + } + out := new(StepStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { *out = *in @@ -1308,6 +1634,56 @@ func (in *TLSConfig) DeepCopy() *TLSConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemporalRetryPolicy) DeepCopyInto(out *TemporalRetryPolicy) { + *out = *in + if in.LLMMaxAttempts != nil { + in, out := &in.LLMMaxAttempts, &out.LLMMaxAttempts + *out = new(int32) + **out = **in + } + if in.ToolMaxAttempts != nil { + in, out := &in.ToolMaxAttempts, &out.ToolMaxAttempts + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalRetryPolicy. +func (in *TemporalRetryPolicy) DeepCopy() *TemporalRetryPolicy { + if in == nil { + return nil + } + out := new(TemporalRetryPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemporalSpec) DeepCopyInto(out *TemporalSpec) { + *out = *in + if in.WorkflowTimeout != nil { + in, out := &in.WorkflowTimeout, &out.WorkflowTimeout + *out = new(metav1.Duration) + **out = **in + } + if in.RetryPolicy != nil { + in, out := &in.RetryPolicy, &out.RetryPolicy + *out = new(TemporalRetryPolicy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalSpec. +func (in *TemporalSpec) DeepCopy() *TemporalSpec { + if in == nil { + return nil + } + out := new(TemporalSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tool) DeepCopyInto(out *Tool) { *out = *in @@ -1404,3 +1780,297 @@ func (in *ValueSource) DeepCopy() *ValueSource { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRetryPolicy) DeepCopyInto(out *WorkflowRetryPolicy) { + *out = *in + out.InitialInterval = in.InitialInterval + out.MaximumInterval = in.MaximumInterval + if in.NonRetryableErrors != nil { + in, out := &in.NonRetryableErrors, &out.NonRetryableErrors + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRetryPolicy. +func (in *WorkflowRetryPolicy) DeepCopy() *WorkflowRetryPolicy { + if in == nil { + return nil + } + out := new(WorkflowRetryPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRun) DeepCopyInto(out *WorkflowRun) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRun. +func (in *WorkflowRun) DeepCopy() *WorkflowRun { + if in == nil { + return nil + } + out := new(WorkflowRun) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkflowRun) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRunList) DeepCopyInto(out *WorkflowRunList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]WorkflowRun, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRunList. +func (in *WorkflowRunList) DeepCopy() *WorkflowRunList { + if in == nil { + return nil + } + out := new(WorkflowRunList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkflowRunList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRunSpec) DeepCopyInto(out *WorkflowRunSpec) { + *out = *in + if in.Params != nil { + in, out := &in.Params, &out.Params + *out = make([]Param, len(*in)) + copy(*out, *in) + } + if in.TTLSecondsAfterFinished != nil { + in, out := &in.TTLSecondsAfterFinished, &out.TTLSecondsAfterFinished + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRunSpec. +func (in *WorkflowRunSpec) DeepCopy() *WorkflowRunSpec { + if in == nil { + return nil + } + out := new(WorkflowRunSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRunStatus) DeepCopyInto(out *WorkflowRunStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ResolvedSpec != nil { + in, out := &in.ResolvedSpec, &out.ResolvedSpec + *out = new(WorkflowTemplateSpec) + (*in).DeepCopyInto(*out) + } + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } + if in.Steps != nil { + in, out := &in.Steps, &out.Steps + *out = make([]StepStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRunStatus. +func (in *WorkflowRunStatus) DeepCopy() *WorkflowRunStatus { + if in == nil { + return nil + } + out := new(WorkflowRunStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowTemplate) DeepCopyInto(out *WorkflowTemplate) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowTemplate. +func (in *WorkflowTemplate) DeepCopy() *WorkflowTemplate { + if in == nil { + return nil + } + out := new(WorkflowTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkflowTemplate) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowTemplateList) DeepCopyInto(out *WorkflowTemplateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]WorkflowTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowTemplateList. +func (in *WorkflowTemplateList) DeepCopy() *WorkflowTemplateList { + if in == nil { + return nil + } + out := new(WorkflowTemplateList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkflowTemplateList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowTemplateSpec) DeepCopyInto(out *WorkflowTemplateSpec) { + *out = *in + if in.Params != nil { + in, out := &in.Params, &out.Params + *out = make([]ParamSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Steps != nil { + in, out := &in.Steps, &out.Steps + *out = make([]StepSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Defaults != nil { + in, out := &in.Defaults, &out.Defaults + *out = new(StepPolicyDefaults) + (*in).DeepCopyInto(*out) + } + if in.Retention != nil { + in, out := &in.Retention, &out.Retention + *out = new(RetentionPolicy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowTemplateSpec. +func (in *WorkflowTemplateSpec) DeepCopy() *WorkflowTemplateSpec { + if in == nil { + return nil + } + out := new(WorkflowTemplateSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowTemplateStatus) DeepCopyInto(out *WorkflowTemplateStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowTemplateStatus. +func (in *WorkflowTemplateStatus) DeepCopy() *WorkflowTemplateStatus { + if in == nil { + return nil + } + out := new(WorkflowTemplateStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowTimeoutPolicy) DeepCopyInto(out *WorkflowTimeoutPolicy) { + *out = *in + out.StartToClose = in.StartToClose + if in.ScheduleToClose != nil { + in, out := &in.ScheduleToClose, &out.ScheduleToClose + *out = new(metav1.Duration) + **out = **in + } + if in.Heartbeat != nil { + in, out := &in.Heartbeat, &out.Heartbeat + *out = new(metav1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowTimeoutPolicy. +func (in *WorkflowTimeoutPolicy) DeepCopy() *WorkflowTimeoutPolicy { + if in == nil { + return nil + } + out := new(WorkflowTimeoutPolicy) + in.DeepCopyInto(out) + return out +} diff --git a/go/core/cli/internal/cli/agent/invoke.go b/go/core/cli/internal/cli/agent/invoke.go index eab8c40f6..38ee9dda8 100644 --- a/go/core/cli/internal/cli/agent/invoke.go +++ b/go/core/cli/internal/cli/agent/invoke.go @@ -84,7 +84,7 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { return } - a2aURL := fmt.Sprintf("%s/api/a2a/%s/%s", cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent) + a2aURL := fmt.Sprintf("%s/api/a2a/%s/%s/", cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent) a2aClient, err = a2aclient.NewA2AClient(a2aURL, a2aclient.WithTimeout(cfg.Config.Timeout)) if err != nil { fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) diff --git a/go/core/go.mod b/go/core/go.mod index b814a3801..be845b509 100644 --- a/go/core/go.mod +++ b/go/core/go.mod @@ -22,11 +22,13 @@ require ( github.com/muesli/reflow v0.3.0 github.com/pgvector/pgvector-go v0.3.0 github.com/prometheus/client_golang v1.23.2 + github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stoewer/go-strcase v1.3.1 github.com/stretchr/testify v1.11.1 + go.temporal.io/sdk v1.40.0 go.uber.org/automaxprocs v1.6.0 golang.org/x/text v0.33.0 google.golang.org/protobuf v1.36.9 @@ -66,6 +68,7 @@ require ( github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -78,13 +81,16 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang/mock v1.6.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -114,6 +120,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nexus-rpc/sdk-go v0.5.1 // indirect github.com/openai/openai-go/v3 v3.15.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -122,6 +129,7 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron v1.2.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/segmentio/asm v1.2.0 // indirect @@ -129,6 +137,7 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect @@ -147,6 +156,7 @@ require ( go.opentelemetry.io/otel/sdk v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.temporal.io/api v1.62.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/go/core/go.sum b/go/core/go.sum index 2b0d8ae6e..2cbae50da 100644 --- a/go/core/go.sum +++ b/go/core/go.sum @@ -74,6 +74,8 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= @@ -116,8 +118,12 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -139,6 +145,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= @@ -171,6 +179,8 @@ github.com/kagent-dev/kmcp v0.2.7 h1:aDPpsmJVYqigC0inZablon1ap7GDBi8R+KRqH3OFTM0 github.com/kagent-dev/kmcp v0.2.7/go.mod h1:g7wS/3m2wonRo/1DMwVoHxnilr/urPgV2hwV1DwkwrQ= github.com/kagent-dev/mockllm v0.0.5 h1:mm9Ml3NH6/E/YKVMgMwWYMNsNGkDze6I6TC0ppHZAo8= github.com/kagent-dev/mockllm v0.0.5/go.mod h1:tDLemRsTZa1NdHaDbg3sgFk9cT1QWvMPlBtLVD6I2mA= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -230,6 +240,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nexus-rpc/sdk-go v0.5.1 h1:UFYYfoHlQc+Pn9gQpmn9QE7xluewAn2AO1OSkAh7YFU= +github.com/nexus-rpc/sdk-go v0.5.1/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= @@ -262,6 +274,10 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -338,6 +354,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -358,6 +377,10 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.temporal.io/api v1.62.2 h1:jFhIzlqNyJsJZTiCRQmTIMv6OTQ5BZ57z8gbgLGMaoo= +go.temporal.io/api v1.62.2/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +go.temporal.io/sdk v1.40.0 h1:n9JN3ezVpWBxLzz5xViCo0sKxp7kVVhr1Su0bcMRNNs= +go.temporal.io/sdk v1.40.0/go.mod h1:tauxVfN174F0bdEs27+i0h8UPD7xBb6Py2SPHo7f1C0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -370,33 +393,66 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= diff --git a/go/core/internal/compiler/dag.go b/go/core/internal/compiler/dag.go new file mode 100644 index 000000000..7a8f358a8 --- /dev/null +++ b/go/core/internal/compiler/dag.go @@ -0,0 +1,296 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compiler + +import ( + "encoding/json" + "fmt" + "strconv" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" +) + +const maxStepCount = 200 + +// ExecutionPlan is the JSON-serializable input to the DAGWorkflow Temporal interpreter. +type ExecutionPlan struct { + WorkflowID string `json:"workflowID"` + TaskQueue string `json:"taskQueue"` + Params map[string]string `json:"params"` + Steps []ExecutionStep `json:"steps"` + Defaults *v1alpha2.StepPolicyDefaults `json:"defaults,omitempty"` +} + +// ExecutionStep represents a single step in the execution plan with merged policies. +type ExecutionStep struct { + Name string `json:"name"` + Type v1alpha2.StepType `json:"type"` + Action string `json:"action,omitempty"` + AgentRef string `json:"agentRef,omitempty"` + Prompt string `json:"prompt,omitempty"` + With map[string]string `json:"with,omitempty"` + DependsOn []string `json:"dependsOn,omitempty"` + Output *v1alpha2.StepOutput `json:"output,omitempty"` + Policy *v1alpha2.StepPolicy `json:"policy,omitempty"` + OnFailure string `json:"onFailure,omitempty"` +} + +// DAGCompiler validates WorkflowTemplateSpec and produces ExecutionPlans. +type DAGCompiler struct{} + +// NewDAGCompiler creates a new DAGCompiler. +func NewDAGCompiler() *DAGCompiler { + return &DAGCompiler{} +} + +// Validate checks a WorkflowTemplateSpec for structural and semantic errors. +func (c *DAGCompiler) Validate(spec *v1alpha2.WorkflowTemplateSpec) error { + if len(spec.Steps) == 0 { + return fmt.Errorf("workflow must have at least one step") + } + if len(spec.Steps) > maxStepCount { + return fmt.Errorf("workflow has %d steps, maximum is %d", len(spec.Steps), maxStepCount) + } + + // Build step name index. + stepNames := make(map[string]bool, len(spec.Steps)) + for _, s := range spec.Steps { + if stepNames[s.Name] { + return fmt.Errorf("duplicate step name: %q", s.Name) + } + stepNames[s.Name] = true + } + + // Validate each step. + for _, s := range spec.Steps { + if err := validateStep(s, stepNames); err != nil { + return fmt.Errorf("step %q: %w", s.Name, err) + } + } + + // Cycle detection via topological sort (Kahn's algorithm). + if err := detectCycles(spec.Steps, stepNames); err != nil { + return err + } + + return nil +} + +// Compile validates params and produces an ExecutionPlan ready for Temporal submission. +func (c *DAGCompiler) Compile(spec *v1alpha2.WorkflowTemplateSpec, params map[string]string, workflowID, taskQueue string) (*ExecutionPlan, error) { + if err := c.Validate(spec); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + resolvedParams, err := resolveParams(spec.Params, params) + if err != nil { + return nil, fmt.Errorf("parameter resolution failed: %w", err) + } + + steps := make([]ExecutionStep, 0, len(spec.Steps)) + for _, s := range spec.Steps { + es := ExecutionStep{ + Name: s.Name, + Type: s.Type, + Action: s.Action, + AgentRef: s.AgentRef, + Prompt: s.Prompt, + With: s.With, + DependsOn: s.DependsOn, + Output: s.Output, + OnFailure: s.OnFailure, + } + es.Policy = mergePolicies(s.Policy, spec.Defaults) + steps = append(steps, es) + } + + plan := &ExecutionPlan{ + WorkflowID: workflowID, + TaskQueue: taskQueue, + Params: resolvedParams, + Steps: steps, + Defaults: spec.Defaults, + } + + // Verify the plan is JSON-serializable. + if _, err := json.Marshal(plan); err != nil { + return nil, fmt.Errorf("execution plan is not JSON-serializable: %w", err) + } + + return plan, nil +} + +// validateStep checks a single step for type-specific requirements and valid dependencies. +func validateStep(s v1alpha2.StepSpec, stepNames map[string]bool) error { + switch s.Type { + case v1alpha2.StepTypeAction: + if s.Action == "" { + return fmt.Errorf("action step must have 'action' field") + } + case v1alpha2.StepTypeAgent: + if s.AgentRef == "" { + return fmt.Errorf("agent step must have 'agentRef' field") + } + default: + return fmt.Errorf("unknown step type: %q", s.Type) + } + + for _, dep := range s.DependsOn { + if !stepNames[dep] { + return fmt.Errorf("depends on nonexistent step: %q", dep) + } + if dep == s.Name { + return fmt.Errorf("step cannot depend on itself") + } + } + + return nil +} + +// detectCycles uses Kahn's algorithm (topological sort) to detect cycles in the DAG. +func detectCycles(steps []v1alpha2.StepSpec, stepNames map[string]bool) error { + // Build adjacency list and in-degree counts. + inDegree := make(map[string]int, len(steps)) + dependents := make(map[string][]string, len(steps)) + + for _, s := range steps { + if _, ok := inDegree[s.Name]; !ok { + inDegree[s.Name] = 0 + } + for _, dep := range s.DependsOn { + dependents[dep] = append(dependents[dep], s.Name) + inDegree[s.Name]++ + } + } + + // Queue nodes with zero in-degree. + queue := make([]string, 0) + for _, s := range steps { + if inDegree[s.Name] == 0 { + queue = append(queue, s.Name) + } + } + + sorted := 0 + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + sorted++ + + for _, dep := range dependents[node] { + inDegree[dep]-- + if inDegree[dep] == 0 { + queue = append(queue, dep) + } + } + } + + if sorted != len(steps) { + return fmt.Errorf("cycle detected in step dependencies") + } + return nil +} + +// resolveParams validates and resolves parameter values against their specifications. +func resolveParams(specs []v1alpha2.ParamSpec, provided map[string]string) (map[string]string, error) { + resolved := make(map[string]string, len(specs)) + + for _, ps := range specs { + val, ok := provided[ps.Name] + if !ok { + if ps.Default != nil { + val = *ps.Default + } else { + return nil, fmt.Errorf("required parameter %q not provided", ps.Name) + } + } + + // Enum validation. + if len(ps.Enum) > 0 { + found := false + for _, e := range ps.Enum { + if val == e { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("parameter %q value %q not in enum %v", ps.Name, val, ps.Enum) + } + } + + // Type validation. + switch ps.Type { + case v1alpha2.ParamTypeNumber: + if _, err := strconv.ParseFloat(val, 64); err != nil { + return nil, fmt.Errorf("parameter %q: expected number, got %q", ps.Name, val) + } + case v1alpha2.ParamTypeBoolean: + if _, err := strconv.ParseBool(val); err != nil { + return nil, fmt.Errorf("parameter %q: expected boolean, got %q", ps.Name, val) + } + case v1alpha2.ParamTypeString, "": + // All values are valid strings. + } + + resolved[ps.Name] = val + } + + return resolved, nil +} + +// mergePolicies merges step-level policies with template defaults. +// Step-level policies take precedence over defaults. +func mergePolicies(stepPolicy *v1alpha2.StepPolicy, defaults *v1alpha2.StepPolicyDefaults) *v1alpha2.StepPolicy { + if defaults == nil && stepPolicy == nil { + return nil + } + if defaults == nil { + return stepPolicy + } + + result := &v1alpha2.StepPolicy{} + + // Merge retry policy. + if stepPolicy != nil && stepPolicy.Retry != nil { + result.Retry = stepPolicy.Retry + } else if defaults.Retry != nil { + result.Retry = &v1alpha2.WorkflowRetryPolicy{ + MaxAttempts: defaults.Retry.MaxAttempts, + InitialInterval: defaults.Retry.InitialInterval, + MaximumInterval: defaults.Retry.MaximumInterval, + BackoffCoefficient: defaults.Retry.BackoffCoefficient, + NonRetryableErrors: defaults.Retry.NonRetryableErrors, + } + } + + // Merge timeout policy. + if stepPolicy != nil && stepPolicy.Timeout != nil { + result.Timeout = stepPolicy.Timeout + } else if defaults.Timeout != nil { + result.Timeout = &v1alpha2.WorkflowTimeoutPolicy{ + StartToClose: defaults.Timeout.StartToClose, + ScheduleToClose: defaults.Timeout.ScheduleToClose, + Heartbeat: defaults.Timeout.Heartbeat, + } + } + + if result.Retry == nil && result.Timeout == nil { + return nil + } + return result +} diff --git a/go/core/internal/compiler/dag_test.go b/go/core/internal/compiler/dag_test.go new file mode 100644 index 000000000..330b16af6 --- /dev/null +++ b/go/core/internal/compiler/dag_test.go @@ -0,0 +1,348 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compiler + +import ( + "fmt" + "strings" + "testing" + "time" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func ptr(s string) *string { return &s } + +func TestDAGCompiler_Validate(t *testing.T) { + tests := []struct { + name string + spec v1alpha2.WorkflowTemplateSpec + wantErr string + }{ + { + name: "valid linear DAG A->B->C", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a"}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "do-b", DependsOn: []string{"a"}}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "do-c", DependsOn: []string{"b"}}, + }, + }, + wantErr: "", + }, + { + name: "valid parallel DAG A->[B,C]->D", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a"}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "do-b", DependsOn: []string{"a"}}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "do-c", DependsOn: []string{"a"}}, + {Name: "d", Type: v1alpha2.StepTypeAction, Action: "do-d", DependsOn: []string{"b", "c"}}, + }, + }, + wantErr: "", + }, + { + name: "valid agent step", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "analyze", Type: v1alpha2.StepTypeAgent, AgentRef: "my-agent", Prompt: "analyze this"}, + }, + }, + wantErr: "", + }, + { + name: "empty steps", + spec: v1alpha2.WorkflowTemplateSpec{Steps: []v1alpha2.StepSpec{}}, + wantErr: "at least one step", + }, + { + name: "duplicate step names", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a"}, + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-b"}, + }, + }, + wantErr: "duplicate step name", + }, + { + name: "dependency on nonexistent step", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a", DependsOn: []string{"missing"}}, + }, + }, + wantErr: "nonexistent step", + }, + { + name: "self dependency", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a", DependsOn: []string{"a"}}, + }, + }, + wantErr: "depend on itself", + }, + { + name: "cycle A->B->C->A", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a", DependsOn: []string{"c"}}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "do-b", DependsOn: []string{"a"}}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "do-c", DependsOn: []string{"b"}}, + }, + }, + wantErr: "cycle detected", + }, + { + name: "action step missing action field", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction}, + }, + }, + wantErr: "must have 'action' field", + }, + { + name: "agent step missing agentRef", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAgent}, + }, + }, + wantErr: "must have 'agentRef' field", + }, + } + + compiler := NewDAGCompiler() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := compiler.Validate(&tt.spec) + if tt.wantErr == "" { + if err != nil { + t.Errorf("Validate() unexpected error: %v", err) + } + } else { + if err == nil { + t.Errorf("Validate() expected error containing %q, got nil", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Validate() error = %q, want containing %q", err.Error(), tt.wantErr) + } + } + }) + } +} + +func TestDAGCompiler_Compile(t *testing.T) { + tests := []struct { + name string + spec v1alpha2.WorkflowTemplateSpec + params map[string]string + wantErr string + wantSteps int + checkPlan func(t *testing.T, plan *ExecutionPlan) + }{ + { + name: "simple compile with params", + spec: v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "url", Type: v1alpha2.ParamTypeString}, + {Name: "retries", Type: v1alpha2.ParamTypeNumber, Default: ptr("3")}, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "fetch", Type: v1alpha2.StepTypeAction, Action: "http.request", + With: map[string]string{"url": "${{ params.url }}"}}, + }, + }, + params: map[string]string{"url": "https://example.com"}, + wantSteps: 1, + checkPlan: func(t *testing.T, plan *ExecutionPlan) { + if plan.Params["url"] != "https://example.com" { + t.Errorf("expected param url=https://example.com, got %q", plan.Params["url"]) + } + if plan.Params["retries"] != "3" { + t.Errorf("expected param retries=3, got %q", plan.Params["retries"]) + } + }, + }, + { + name: "missing required param", + spec: v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "url", Type: v1alpha2.ParamTypeString}, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "fetch", Type: v1alpha2.StepTypeAction, Action: "http.request"}, + }, + }, + params: map[string]string{}, + wantErr: "required parameter", + }, + { + name: "invalid enum value", + spec: v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "env", Type: v1alpha2.ParamTypeString, Enum: []string{"dev", "staging", "prod"}}, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "deploy", Type: v1alpha2.StepTypeAction, Action: "deploy"}, + }, + }, + params: map[string]string{"env": "local"}, + wantErr: "not in enum", + }, + { + name: "invalid number param", + spec: v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "count", Type: v1alpha2.ParamTypeNumber}, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "run", Type: v1alpha2.StepTypeAction, Action: "run"}, + }, + }, + params: map[string]string{"count": "not-a-number"}, + wantErr: "expected number", + }, + { + name: "invalid boolean param", + spec: v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "verbose", Type: v1alpha2.ParamTypeBoolean}, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "run", Type: v1alpha2.StepTypeAction, Action: "run"}, + }, + }, + params: map[string]string{"verbose": "maybe"}, + wantErr: "expected boolean", + }, + { + name: "policy merging - step overrides defaults", + spec: v1alpha2.WorkflowTemplateSpec{ + Defaults: &v1alpha2.StepPolicyDefaults{ + Retry: &v1alpha2.WorkflowRetryPolicy{ + MaxAttempts: 5, + }, + Timeout: &v1alpha2.WorkflowTimeoutPolicy{ + StartToClose: metav1.Duration{Duration: 10 * time.Minute}, + }, + }, + Steps: []v1alpha2.StepSpec{ + { + Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a", + Policy: &v1alpha2.StepPolicy{ + Retry: &v1alpha2.WorkflowRetryPolicy{MaxAttempts: 2}, + }, + }, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "do-b"}, + }, + }, + params: map[string]string{}, + wantSteps: 2, + checkPlan: func(t *testing.T, plan *ExecutionPlan) { + // Step A: step-level retry overrides default, but timeout from defaults + stepA := plan.Steps[0] + if stepA.Policy == nil { + t.Fatal("step a should have merged policy") + } + if stepA.Policy.Retry.MaxAttempts != 2 { + t.Errorf("step a retry: want 2, got %d", stepA.Policy.Retry.MaxAttempts) + } + if stepA.Policy.Timeout.StartToClose.Duration != 10*time.Minute { + t.Errorf("step a timeout: want 10m, got %v", stepA.Policy.Timeout.StartToClose.Duration) + } + + // Step B: inherits all defaults + stepB := plan.Steps[1] + if stepB.Policy == nil { + t.Fatal("step b should have default policy") + } + if stepB.Policy.Retry.MaxAttempts != 5 { + t.Errorf("step b retry: want 5, got %d", stepB.Policy.Retry.MaxAttempts) + } + }, + }, + { + name: "plan includes workflowID and taskQueue", + spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "do-a"}, + }, + }, + params: map[string]string{}, + wantSteps: 1, + checkPlan: func(t *testing.T, plan *ExecutionPlan) { + if plan.WorkflowID != "test-wf-id" { + t.Errorf("expected workflowID=test-wf-id, got %q", plan.WorkflowID) + } + if plan.TaskQueue != "kagent-workflows" { + t.Errorf("expected taskQueue=kagent-workflows, got %q", plan.TaskQueue) + } + }, + }, + } + + compiler := NewDAGCompiler() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan, err := compiler.Compile(&tt.spec, tt.params, "test-wf-id", "kagent-workflows") + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Compile() unexpected error: %v", err) + } + if tt.wantSteps > 0 && len(plan.Steps) != tt.wantSteps { + t.Errorf("expected %d steps, got %d", tt.wantSteps, len(plan.Steps)) + } + if tt.checkPlan != nil { + tt.checkPlan(t, plan) + } + } else { + if err == nil { + t.Errorf("Compile() expected error containing %q, got nil", tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Compile() error = %q, want containing %q", err.Error(), tt.wantErr) + } + } + }) + } +} + +func TestDAGCompiler_Validate_StepCountLimit(t *testing.T) { + steps := make([]v1alpha2.StepSpec, maxStepCount+1) + for i := range steps { + steps[i] = v1alpha2.StepSpec{ + Name: fmt.Sprintf("step-%d", i), + Type: v1alpha2.StepTypeAction, + Action: "noop", + } + } + + compiler := NewDAGCompiler() + err := compiler.Validate(&v1alpha2.WorkflowTemplateSpec{Steps: steps}) + if err == nil { + t.Error("expected error for exceeding step count limit") + } + if !strings.Contains(err.Error(), "maximum is 200") { + t.Errorf("expected step count error, got: %v", err) + } +} + diff --git a/go/core/internal/compiler/expr.go b/go/core/internal/compiler/expr.go new file mode 100644 index 000000000..f879ccbaf --- /dev/null +++ b/go/core/internal/compiler/expr.go @@ -0,0 +1,246 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compiler + +import ( + "encoding/json" + "fmt" + "strings" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" +) + +// Expression represents a parsed ${{ }} token found in a string. +type Expression struct { + // Raw is the full match including delimiters, e.g. "${{ params.url }}". + Raw string + // Namespace is the first segment: "params", "context", or "workflow". + Namespace string + // Path is everything after the namespace dot, e.g. "url" or "checkout.path". + Path string +} + +// WorkflowContext holds step outputs and workflow metadata used during expression resolution. +type WorkflowContext struct { + // StepOutputs maps step name (or alias) to JSON-encoded output. + StepOutputs map[string]json.RawMessage + // Globals maps top-level context keys (from output.keys) to their values. + Globals map[string]string + // WorkflowName is the workflow template name. + WorkflowName string + // WorkflowNamespace is the Kubernetes namespace. + WorkflowNamespace string + // WorkflowRunName is the WorkflowRun resource name. + WorkflowRunName string +} + +// ExtractExpressions parses all ${{ }} tokens from a string. +// Escaped expressions ($${{ }}) are not included. +func ExtractExpressions(s string) []Expression { + var exprs []Expression + remaining := s + for { + idx := strings.Index(remaining, "${{") + if idx < 0 { + break + } + // Check for escape: $$ prefix. + if idx > 0 && remaining[idx-1] == '$' { + remaining = remaining[idx+3:] + continue + } + end := strings.Index(remaining[idx:], "}}") + if end < 0 { + break + } + end += idx // Absolute position of "}}". + raw := remaining[idx : end+2] + inner := strings.TrimSpace(remaining[idx+3 : end]) + + parts := strings.SplitN(inner, ".", 2) + expr := Expression{Raw: raw, Namespace: parts[0]} + if len(parts) > 1 { + expr.Path = parts[1] + } + exprs = append(exprs, expr) + remaining = remaining[end+2:] + } + return exprs +} + +// ResolveExpression resolves all ${{ }} expressions in a string. +// params provides parameter values, ctx provides step outputs and metadata. +// ctx may be nil if only param resolution is needed (compile-time). +func ResolveExpression(expr string, params map[string]string, ctx *WorkflowContext) (string, error) { + result := expr + // Process escapes first: replace $${{ with a placeholder. + const escapePlaceholder = "\x00EXPR_ESCAPE\x00" + result = strings.ReplaceAll(result, "$${{", escapePlaceholder) + + tokens := ExtractExpressions(result) + for _, tok := range tokens { + resolved, err := resolveToken(tok, params, ctx) + if err != nil { + return "", err + } + result = strings.Replace(result, tok.Raw, resolved, 1) + } + + // Restore escaped expressions. + result = strings.ReplaceAll(result, escapePlaceholder, "${{") + return result, nil +} + +// resolveToken resolves a single expression token. +func resolveToken(tok Expression, params map[string]string, ctx *WorkflowContext) (string, error) { + switch tok.Namespace { + case "params": + if tok.Path == "" { + return "", fmt.Errorf("expression %q: missing parameter name", tok.Raw) + } + val, ok := params[tok.Path] + if !ok { + return "", fmt.Errorf("expression %q: unknown parameter %q", tok.Raw, tok.Path) + } + return val, nil + + case "context": + if ctx == nil { + return "", fmt.Errorf("expression %q: context not available at compile time", tok.Raw) + } + if tok.Path == "" { + return "", fmt.Errorf("expression %q: missing context path", tok.Raw) + } + return resolveContextPath(tok, ctx) + + case "workflow": + if ctx == nil { + return "", fmt.Errorf("expression %q: workflow metadata not available at compile time", tok.Raw) + } + return resolveWorkflowMeta(tok, ctx) + + default: + return "", fmt.Errorf("expression %q: unknown namespace %q (expected params, context, or workflow)", tok.Raw, tok.Namespace) + } +} + +// resolveContextPath resolves a context.stepName.field or context.globalKey expression. +func resolveContextPath(tok Expression, ctx *WorkflowContext) (string, error) { + parts := strings.SplitN(tok.Path, ".", 2) + stepOrKey := parts[0] + + // Try step output first (context.stepName.field). + if len(parts) == 2 { + raw, ok := ctx.StepOutputs[stepOrKey] + if !ok { + return "", fmt.Errorf("expression %q: no output from step %q", tok.Raw, stepOrKey) + } + return extractJSONField(tok.Raw, raw, parts[1]) + } + + // Single segment: try step output (returns full JSON), then globals. + if raw, ok := ctx.StepOutputs[stepOrKey]; ok { + // Return the raw JSON as a string. + return strings.TrimSpace(string(raw)), nil + } + if val, ok := ctx.Globals[stepOrKey]; ok { + return val, nil + } + return "", fmt.Errorf("expression %q: unknown context key %q", tok.Raw, stepOrKey) +} + +// extractJSONField extracts a field from JSON data. Supports dotted paths for nested access. +func extractJSONField(rawExpr string, data json.RawMessage, field string) (string, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return "", fmt.Errorf("expression %q: step output is not a JSON object: %w", rawExpr, err) + } + + parts := strings.SplitN(field, ".", 2) + val, ok := obj[parts[0]] + if !ok { + return "", fmt.Errorf("expression %q: field %q not found in step output", rawExpr, parts[0]) + } + + // Nested access. + if len(parts) == 2 { + return extractJSONField(rawExpr, val, parts[1]) + } + + // Unwrap JSON strings, return other types as-is. + var s string + if err := json.Unmarshal(val, &s); err == nil { + return s, nil + } + return strings.TrimSpace(string(val)), nil +} + +// resolveWorkflowMeta resolves workflow.* expressions. +func resolveWorkflowMeta(tok Expression, ctx *WorkflowContext) (string, error) { + switch tok.Path { + case "name": + return ctx.WorkflowName, nil + case "namespace": + return ctx.WorkflowNamespace, nil + case "runName": + return ctx.WorkflowRunName, nil + default: + return "", fmt.Errorf("expression %q: unknown workflow field %q (expected name, namespace, or runName)", tok.Raw, tok.Path) + } +} + +// ValidateExpressions statically checks all ${{ params.* }} references in a WorkflowTemplateSpec +// to ensure they refer to declared parameters. Context references are not validated here +// since they depend on runtime execution order. +func ValidateExpressions(spec *v1alpha2.WorkflowTemplateSpec) []error { + paramNames := make(map[string]bool, len(spec.Params)) + for _, p := range spec.Params { + paramNames[p.Name] = true + } + + var errs []error + for _, step := range spec.Steps { + // Check prompt field. + if step.Prompt != "" { + errs = append(errs, validateParamRefs(step.Name, "prompt", step.Prompt, paramNames)...) + } + // Check action field. + if step.Action != "" { + errs = append(errs, validateParamRefs(step.Name, "action", step.Action, paramNames)...) + } + // Check with map values. + for k, v := range step.With { + errs = append(errs, validateParamRefs(step.Name, fmt.Sprintf("with[%s]", k), v, paramNames)...) + } + } + return errs +} + +// validateParamRefs checks that all ${{ params.* }} expressions in a string refer to declared params. +func validateParamRefs(stepName, fieldName, value string, paramNames map[string]bool) []error { + var errs []error + for _, expr := range ExtractExpressions(value) { + if expr.Namespace == "params" { + if expr.Path == "" { + errs = append(errs, fmt.Errorf("step %q field %q: expression %q missing parameter name", stepName, fieldName, expr.Raw)) + } else if !paramNames[expr.Path] { + errs = append(errs, fmt.Errorf("step %q field %q: expression %q references undeclared parameter %q", stepName, fieldName, expr.Raw, expr.Path)) + } + } + } + return errs +} diff --git a/go/core/internal/compiler/expr_test.go b/go/core/internal/compiler/expr_test.go new file mode 100644 index 000000000..0c9a97ed3 --- /dev/null +++ b/go/core/internal/compiler/expr_test.go @@ -0,0 +1,396 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compiler + +import ( + "encoding/json" + "testing" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" +) + +func TestExtractExpressions(t *testing.T) { + tests := []struct { + name string + input string + want int + exprs []Expression + }{ + { + name: "no expressions", + input: "plain text", + want: 0, + }, + { + name: "single param", + input: "${{ params.url }}", + want: 1, + exprs: []Expression{{Raw: "${{ params.url }}", Namespace: "params", Path: "url"}}, + }, + { + name: "context with dotted path", + input: "${{ context.checkout.path }}", + want: 1, + exprs: []Expression{{Raw: "${{ context.checkout.path }}", Namespace: "context", Path: "checkout.path"}}, + }, + { + name: "multiple expressions", + input: "${{ params.a }}-${{ params.b }}", + want: 2, + }, + { + name: "escaped expression not extracted", + input: "$${{ not.resolved }}", + want: 0, + }, + { + name: "workflow namespace", + input: "${{ workflow.name }}", + want: 1, + exprs: []Expression{{Raw: "${{ workflow.name }}", Namespace: "workflow", Path: "name"}}, + }, + { + name: "no closing braces", + input: "${{ params.url", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractExpressions(tt.input) + if len(got) != tt.want { + t.Errorf("ExtractExpressions() returned %d expressions, want %d", len(got), tt.want) + } + if tt.exprs != nil { + for i, e := range tt.exprs { + if i >= len(got) { + break + } + if got[i].Raw != e.Raw || got[i].Namespace != e.Namespace || got[i].Path != e.Path { + t.Errorf("expression[%d] = %+v, want %+v", i, got[i], e) + } + } + } + }) + } +} + +func TestResolveExpression(t *testing.T) { + params := map[string]string{ + "url": "https://github.com/example/repo", + "branch": "main", + } + + ctx := &WorkflowContext{ + StepOutputs: map[string]json.RawMessage{ + "checkout": json.RawMessage(`{"path":"/src","commitSha":"abc123"}`), + "build": json.RawMessage(`{"artifact":"/out/build.tar.gz","nested":{"key":"deep"}}`), + }, + Globals: map[string]string{ + "repoPath": "/src", + }, + WorkflowName: "my-workflow", + WorkflowNamespace: "default", + WorkflowRunName: "my-workflow-run-1", + } + + tests := []struct { + name string + expr string + params map[string]string + ctx *WorkflowContext + want string + wantErr bool + }{ + { + name: "no expressions passthrough", + expr: "plain text", + params: params, + ctx: ctx, + want: "plain text", + }, + { + name: "simple param substitution", + expr: "${{ params.url }}", + params: params, + ctx: ctx, + want: "https://github.com/example/repo", + }, + { + name: "param in surrounding text", + expr: "git clone ${{ params.url }} --branch ${{ params.branch }}", + params: params, + ctx: ctx, + want: "git clone https://github.com/example/repo --branch main", + }, + { + name: "context step field", + expr: "${{ context.checkout.path }}", + params: params, + ctx: ctx, + want: "/src", + }, + { + name: "context step nested field", + expr: "${{ context.build.nested.key }}", + params: params, + ctx: ctx, + want: "deep", + }, + { + name: "context global key", + expr: "${{ context.repoPath }}", + params: params, + ctx: ctx, + want: "/src", + }, + { + name: "workflow metadata name", + expr: "${{ workflow.name }}", + params: params, + ctx: ctx, + want: "my-workflow", + }, + { + name: "workflow metadata namespace", + expr: "${{ workflow.namespace }}", + params: params, + ctx: ctx, + want: "default", + }, + { + name: "workflow metadata runName", + expr: "${{ workflow.runName }}", + params: params, + ctx: ctx, + want: "my-workflow-run-1", + }, + { + name: "escape produces literal", + expr: "$${{ not.resolved }}", + params: params, + ctx: ctx, + want: "${{ not.resolved }}", + }, + { + name: "mixed escape and real expression", + expr: "$${{ literal }} and ${{ params.url }}", + params: params, + ctx: ctx, + want: "${{ literal }} and https://github.com/example/repo", + }, + { + name: "unknown parameter", + expr: "${{ params.missing }}", + params: params, + ctx: ctx, + wantErr: true, + }, + { + name: "unknown context step", + expr: "${{ context.nonexistent.field }}", + params: params, + ctx: ctx, + wantErr: true, + }, + { + name: "unknown context key", + expr: "${{ context.unknownKey }}", + params: params, + ctx: ctx, + wantErr: true, + }, + { + name: "unknown workflow field", + expr: "${{ workflow.unknownField }}", + params: params, + ctx: ctx, + wantErr: true, + }, + { + name: "unknown namespace", + expr: "${{ foobar.something }}", + params: params, + ctx: ctx, + wantErr: true, + }, + { + name: "context not available at compile time", + expr: "${{ context.checkout.path }}", + params: params, + ctx: nil, + wantErr: true, + }, + { + name: "params resolve without context", + expr: "${{ params.url }}", + params: params, + ctx: nil, + want: "https://github.com/example/repo", + }, + { + name: "empty param name", + expr: "${{ params. }}", + params: params, + ctx: ctx, + wantErr: true, + }, + { + name: "context step full output", + expr: "${{ context.checkout }}", + params: params, + ctx: ctx, + want: `{"path":"/src","commitSha":"abc123"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveExpression(tt.expr, tt.params, tt.ctx) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveExpression() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("ResolveExpression() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateExpressions(t *testing.T) { + tests := []struct { + name string + spec *v1alpha2.WorkflowTemplateSpec + wantErrs int + }{ + { + name: "valid param references", + spec: &v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "url"}, + {Name: "branch"}, + }, + Steps: []v1alpha2.StepSpec{ + { + Name: "checkout", + Type: v1alpha2.StepTypeAction, + Action: "git-clone", + Prompt: "Clone ${{ params.url }} on ${{ params.branch }}", + }, + }, + }, + wantErrs: 0, + }, + { + name: "undeclared param reference", + spec: &v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "url"}, + }, + Steps: []v1alpha2.StepSpec{ + { + Name: "checkout", + Type: v1alpha2.StepTypeAction, + Action: "git-clone", + Prompt: "Clone ${{ params.url }} on ${{ params.branch }}", + }, + }, + }, + wantErrs: 1, + }, + { + name: "context refs not validated statically", + spec: &v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + { + Name: "deploy", + Type: v1alpha2.StepTypeAction, + Action: "deploy", + Prompt: "Deploy ${{ context.build.artifact }}", + }, + }, + }, + wantErrs: 0, + }, + { + name: "undeclared param in with map", + spec: &v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{ + {Name: "url"}, + }, + Steps: []v1alpha2.StepSpec{ + { + Name: "checkout", + Type: v1alpha2.StepTypeAction, + Action: "git-clone", + With: map[string]string{ + "repo": "${{ params.url }}", + "ref": "${{ params.missing }}", + }, + }, + }, + }, + wantErrs: 1, + }, + { + name: "multiple errors across steps", + spec: &v1alpha2.WorkflowTemplateSpec{ + Params: []v1alpha2.ParamSpec{}, + Steps: []v1alpha2.StepSpec{ + { + Name: "step1", + Type: v1alpha2.StepTypeAction, + Action: "do", + Prompt: "${{ params.a }}", + }, + { + Name: "step2", + Type: v1alpha2.StepTypeAction, + Action: "do", + Prompt: "${{ params.b }}", + }, + }, + }, + wantErrs: 2, + }, + { + name: "no expressions no errors", + spec: &v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + { + Name: "step1", + Type: v1alpha2.StepTypeAction, + Action: "do", + Prompt: "plain text", + }, + }, + }, + wantErrs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateExpressions(tt.spec) + if len(errs) != tt.wantErrs { + t.Errorf("ValidateExpressions() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + }) + } +} diff --git a/go/core/internal/controller/agentcronjob_controller.go b/go/core/internal/controller/agentcronjob_controller.go new file mode 100644 index 000000000..83cb6f209 --- /dev/null +++ b/go/core/internal/controller/agentcronjob_controller.go @@ -0,0 +1,246 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + "github.com/robfig/cron/v3" + + kagentclient "github.com/kagent-dev/kagent/go/api/client" + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" + "trpc.group/trpc-go/trpc-a2a-go/protocol" +) + +const ( + cronJobSystemUser = "system:cronjob@kagent.dev" + cronJobExecTimeout = 5 * time.Minute +) + +// AgentCronJobController reconciles AgentCronJob objects. +// It parses cron schedules, triggers agent runs via the HTTP API, and uses +// RequeueAfter to schedule the next reconciliation at the appropriate time. +type AgentCronJobController struct { + client.Client + Scheme *runtime.Scheme + A2ABaseURL string // Base URL of the kagent HTTP server (e.g., "http://127.0.0.1:8083") +} + +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs/finalizers,verbs=update + +func (r *AgentCronJobController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // 1. Fetch the AgentCronJob CR + var cronJob v1alpha2.AgentCronJob + if err := r.Get(ctx, req.NamespacedName, &cronJob); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // 2. Parse cron schedule + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + schedule, err := parser.Parse(cronJob.Spec.Schedule) + if err != nil { + meta.SetStatusCondition(&cronJob.Status.Conditions, metav1.Condition{ + Type: v1alpha2.AgentCronJobConditionTypeAccepted, + Status: metav1.ConditionFalse, + Reason: "InvalidSchedule", + Message: fmt.Sprintf("Failed to parse cron schedule: %v", err), + ObservedGeneration: cronJob.Generation, + }) + cronJob.Status.ObservedGeneration = cronJob.Generation + if statusErr := r.Status().Update(ctx, &cronJob); statusErr != nil { + return ctrl.Result{}, fmt.Errorf("failed to update status for invalid schedule: %w", statusErr) + } + return ctrl.Result{}, nil + } + + // 3. Set Accepted=True + meta.SetStatusCondition(&cronJob.Status.Conditions, metav1.Condition{ + Type: v1alpha2.AgentCronJobConditionTypeAccepted, + Status: metav1.ConditionTrue, + Reason: "ScheduleValid", + Message: "Cron schedule is valid", + ObservedGeneration: cronJob.Generation, + }) + + // 4. Calculate next run time + now := time.Now() + var referenceTime time.Time + if cronJob.Status.LastRunTime != nil { + referenceTime = cronJob.Status.LastRunTime.Time + } else { + referenceTime = cronJob.CreationTimestamp.Time + } + nextRun := schedule.Next(referenceTime) + + // 5. Check if it's time to run + if !now.Before(nextRun) { + logger.Info("Executing scheduled run", "agentRef", cronJob.Spec.AgentRef, "scheduledTime", nextRun) + + sessionID, execErr := r.executeRun(ctx, &cronJob) + if execErr != nil { + logger.Error(execErr, "Failed to execute cron job run") + cronJob.Status.LastRunResult = "Failed" + cronJob.Status.LastRunMessage = execErr.Error() + } else { + cronJob.Status.LastRunResult = "Success" + cronJob.Status.LastRunMessage = "" + cronJob.Status.LastSessionID = sessionID + } + cronJob.Status.LastRunTime = &metav1.Time{Time: now} + + // Recalculate next run from now + nextRun = schedule.Next(now) + } + + // 6. Update status with next run time + cronJob.Status.NextRunTime = &metav1.Time{Time: nextRun} + + meta.SetStatusCondition(&cronJob.Status.Conditions, metav1.Condition{ + Type: v1alpha2.AgentCronJobConditionTypeReady, + Status: metav1.ConditionTrue, + Reason: "Scheduled", + Message: fmt.Sprintf("Next run at %s", nextRun.Format(time.RFC3339)), + ObservedGeneration: cronJob.Generation, + }) + + cronJob.Status.ObservedGeneration = cronJob.Generation + if err := r.Status().Update(ctx, &cronJob); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update status: %w", err) + } + + // 7. Requeue for next run + requeueAfter := time.Until(nextRun) + if requeueAfter < 0 { + requeueAfter = time.Second + } + logger.Info("Scheduling next reconciliation", "requeueAfter", requeueAfter, "nextRun", nextRun) + + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + +// executeRun creates a session and sends the prompt to the agent via A2A. +func (r *AgentCronJobController) executeRun(ctx context.Context, cronJob *v1alpha2.AgentCronJob) (string, error) { + // Verify the Agent CR exists + var agent v1alpha2.Agent + if err := r.Get(ctx, types.NamespacedName{ + Namespace: cronJob.Namespace, + Name: cronJob.Spec.AgentRef, + }, &agent); err != nil { + return "", fmt.Errorf("agent %q not found: %w", cronJob.Spec.AgentRef, err) + } + + // Create session via HTTP API + baseClient := kagentclient.NewBaseClient(r.A2ABaseURL, kagentclient.WithUserID(cronJobSystemUser)) + sessionClient := kagentclient.NewSessionClient(baseClient) + + sessionName := fmt.Sprintf("cronjob-%s-%d", cronJob.Name, time.Now().Unix()) + agentRef := fmt.Sprintf("%s/%s", cronJob.Namespace, cronJob.Spec.AgentRef) + + sessionResp, err := sessionClient.CreateSession(ctx, &api.SessionRequest{ + AgentRef: &agentRef, + Name: &sessionName, + }) + if err != nil { + return "", fmt.Errorf("failed to create session: %w", err) + } + if sessionResp.Error || sessionResp.Data == nil { + return "", fmt.Errorf("session creation failed: %s", sessionResp.Message) + } + + sessionID := sessionResp.Data.ID + + // Send prompt via A2A + a2aURL := fmt.Sprintf("%s/api/a2a/%s/%s", r.A2ABaseURL, cronJob.Namespace, cronJob.Spec.AgentRef) + + execCtx, cancel := context.WithTimeout(ctx, cronJobExecTimeout) + defer cancel() + + a2aC, err := a2aclient.NewA2AClient(a2aURL, + a2aclient.WithAPIKeyAuth(cronJobSystemUser, "x-user-id"), + a2aclient.WithTimeout(cronJobExecTimeout), + ) + if err != nil { + return sessionID, fmt.Errorf("failed to create A2A client: %w", err) + } + + msg := protocol.Message{ + Kind: protocol.KindMessage, + Role: protocol.MessageRoleUser, + Parts: []protocol.Part{protocol.NewTextPart(cronJob.Spec.Prompt)}, + ContextID: &sessionID, + } + + result, err := a2aC.SendMessage(execCtx, protocol.SendMessageParams{Message: msg}) + if err != nil { + return sessionID, fmt.Errorf("failed to send message to agent: %w", err) + } + + // Check the task result status if it's a Task response. + if result != nil && result.Result != nil { + if task, ok := result.Result.(*protocol.Task); ok { + switch task.Status.State { + case protocol.TaskStateFailed: + msg := "task failed" + if task.Status.Message != nil { + for _, p := range task.Status.Message.Parts { + if tp, ok := p.(protocol.TextPart); ok { + msg = tp.Text + break + } + } + } + return sessionID, fmt.Errorf("agent task failed: %s", msg) + case protocol.TaskStateCanceled: + return sessionID, fmt.Errorf("agent task was cancelled") + } + } + } + + return sessionID, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *AgentCronJobController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + WithOptions(controller.Options{ + NeedLeaderElection: ptr.To(true), + }). + For(&v1alpha2.AgentCronJob{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Named("agentcronjob"). + Complete(r) +} diff --git a/go/core/internal/controller/reconciler/reconciler.go b/go/core/internal/controller/reconciler/reconciler.go index 857f23e77..986a8c913 100644 --- a/go/core/internal/controller/reconciler/reconciler.go +++ b/go/core/internal/controller/reconciler/reconciler.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "reflect" "slices" "strings" @@ -415,6 +416,10 @@ func (a *kagentReconciler) ReconcileKagentRemoteMCPServer(ctx context.Context, r l.Error(err, "failed to delete tools for remote mcp server") } + if err := a.dbClient.DeletePlugin(serverRef); err != nil { + l.Error(err, "failed to delete plugin for remote mcp server") + } + return nil } @@ -439,6 +444,11 @@ func (a *kagentReconciler) ReconcileKagentRemoteMCPServer(ctx context.Context, r } } + // Reconcile plugin UI metadata (non-fatal) + if pluginErr := a.reconcilePluginUI(server); pluginErr != nil { + l.Error(pluginErr, "failed to reconcile plugin UI") + } + // update the tool server status as the agents depend on it if err := a.reconcileRemoteMCPServerStatus( ctx, @@ -497,6 +507,68 @@ func (a *kagentReconciler) reconcileRemoteMCPServerStatus( return nil } +func (a *kagentReconciler) reconcilePluginUI(server *v1alpha2.RemoteMCPServer) error { + serverRef := fmt.Sprintf("%s/%s", server.Namespace, server.Name) + + // If UI not enabled, ensure plugin record is deleted + if server.Spec.UI == nil || !server.Spec.UI.Enabled { + return a.dbClient.DeletePlugin(serverRef) + } + + ui := server.Spec.UI + + // Derive upstream URL from spec.url (strip path to get base) + upstreamURL, err := deriveBaseURL(server.Spec.URL) + if err != nil { + return fmt.Errorf("failed to derive upstream URL: %w", err) + } + + // Derive defaults + pathPrefix := ui.PathPrefix + if pathPrefix == "" { + pathPrefix = server.Name + } + displayName := ui.DisplayName + if displayName == "" { + displayName = server.Name + } + icon := ui.Icon + if icon == "" { + icon = "puzzle" + } + section := ui.Section + if section == "" { + section = "PLUGINS" + } + + plugin := &database.Plugin{ + Name: serverRef, + PathPrefix: pathPrefix, + DisplayName: displayName, + Icon: icon, + Section: section, + UpstreamURL: upstreamURL, + DefaultPath: ui.DefaultPath, + InjectCSS: ui.InjectCSS, + } + + _, err = a.dbClient.StorePlugin(plugin) + return err +} + +// deriveBaseURL strips the path from a URL to get the base (scheme + host). +// e.g., "http://kanban-mcp.kagent.svc:8080/mcp" -> "http://kanban-mcp.kagent.svc:8080" +func deriveBaseURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + u.Path = "" + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + // validateCrossNamespaceReferences validates that any cross-namespace // references in the agent's tools target namespaces that are watched by the // controller. This prevents agents from referencing tools or agents in diff --git a/go/core/internal/controller/reconciler/reconciler_plugin_test.go b/go/core/internal/controller/reconciler/reconciler_plugin_test.go new file mode 100644 index 000000000..7b4fe0492 --- /dev/null +++ b/go/core/internal/controller/reconciler/reconciler_plugin_test.go @@ -0,0 +1,246 @@ +package reconciler + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + fake "github.com/kagent-dev/kagent/go/core/internal/database/fake" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDeriveBaseURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "URL with path", + input: "http://kanban-mcp.kagent.svc:8080/mcp", + want: "http://kanban-mcp.kagent.svc:8080", + }, + { + name: "URL without path", + input: "http://kanban-mcp.kagent.svc:8080", + want: "http://kanban-mcp.kagent.svc:8080", + }, + { + name: "URL with deep path", + input: "http://host:9090/path/to/mcp", + want: "http://host:9090", + }, + { + name: "URL with query and fragment", + input: "http://host:8080/mcp?key=val#frag", + want: "http://host:8080", + }, + { + name: "HTTPS URL", + input: "https://secure-mcp.example.com/mcp", + want: "https://secure-mcp.example.com", + }, + { + name: "invalid URL", + input: "://invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := deriveBaseURL(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("deriveBaseURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("deriveBaseURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func newTestReconciler(t *testing.T) (*kagentReconciler, *fake.InMemoryFakeClient) { + t.Helper() + dbClient := fake.NewClient() + fakeClient := dbClient.(*fake.InMemoryFakeClient) + r := &kagentReconciler{dbClient: dbClient} + return r, fakeClient +} + +func makeRemoteMCPServer(namespace, name, url string, ui *v1alpha2.PluginUISpec) *v1alpha2.RemoteMCPServer { + return &v1alpha2.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: v1alpha2.RemoteMCPServerSpec{ + URL: url, + UI: ui, + }, + } +} + +func TestReconcilePluginUI_CreateWithAllFields(t *testing.T) { + r, fakeClient := newTestReconciler(t) + + server := makeRemoteMCPServer("kagent", "kanban-mcp", "http://kanban-mcp:8080/mcp", &v1alpha2.PluginUISpec{ + Enabled: true, + PathPrefix: "kanban", + DisplayName: "Kanban Board", + Icon: "kanban", + Section: "AGENTS", + }) + + err := r.reconcilePluginUI(server) + if err != nil { + t.Fatalf("reconcilePluginUI() error = %v", err) + } + + plugins, _ := fakeClient.ListPlugins() + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + + p := plugins[0] + if p.Name != "kagent/kanban-mcp" { + t.Errorf("Name = %q, want %q", p.Name, "kagent/kanban-mcp") + } + if p.PathPrefix != "kanban" { + t.Errorf("PathPrefix = %q, want %q", p.PathPrefix, "kanban") + } + if p.DisplayName != "Kanban Board" { + t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Kanban Board") + } + if p.Icon != "kanban" { + t.Errorf("Icon = %q, want %q", p.Icon, "kanban") + } + if p.Section != "AGENTS" { + t.Errorf("Section = %q, want %q", p.Section, "AGENTS") + } + if p.UpstreamURL != "http://kanban-mcp:8080" { + t.Errorf("UpstreamURL = %q, want %q", p.UpstreamURL, "http://kanban-mcp:8080") + } +} + +func TestReconcilePluginUI_DefaultValues(t *testing.T) { + r, fakeClient := newTestReconciler(t) + + server := makeRemoteMCPServer("default", "my-plugin", "http://my-plugin:9090/api", &v1alpha2.PluginUISpec{ + Enabled: true, + }) + + err := r.reconcilePluginUI(server) + if err != nil { + t.Fatalf("reconcilePluginUI() error = %v", err) + } + + plugins, _ := fakeClient.ListPlugins() + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + + p := plugins[0] + if p.PathPrefix != "my-plugin" { + t.Errorf("PathPrefix default = %q, want %q", p.PathPrefix, "my-plugin") + } + if p.DisplayName != "my-plugin" { + t.Errorf("DisplayName default = %q, want %q", p.DisplayName, "my-plugin") + } + if p.Icon != "puzzle" { + t.Errorf("Icon default = %q, want %q", p.Icon, "puzzle") + } + if p.Section != "PLUGINS" { + t.Errorf("Section default = %q, want %q", p.Section, "PLUGINS") + } +} + +func TestReconcilePluginUI_DeleteWhenDisabled(t *testing.T) { + r, fakeClient := newTestReconciler(t) + + // First create + server := makeRemoteMCPServer("kagent", "kanban-mcp", "http://kanban-mcp:8080/mcp", &v1alpha2.PluginUISpec{ + Enabled: true, + PathPrefix: "kanban", + }) + if err := r.reconcilePluginUI(server); err != nil { + t.Fatalf("create error = %v", err) + } + + plugins, _ := fakeClient.ListPlugins() + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin after create, got %d", len(plugins)) + } + + // Disable + server.Spec.UI.Enabled = false + if err := r.reconcilePluginUI(server); err != nil { + t.Fatalf("disable error = %v", err) + } + + plugins, _ = fakeClient.ListPlugins() + if len(plugins) != 0 { + t.Errorf("expected 0 plugins after disable, got %d", len(plugins)) + } +} + +func TestReconcilePluginUI_DeleteWhenUIIsNil(t *testing.T) { + r, fakeClient := newTestReconciler(t) + + // First create + server := makeRemoteMCPServer("kagent", "kanban-mcp", "http://kanban-mcp:8080/mcp", &v1alpha2.PluginUISpec{ + Enabled: true, + PathPrefix: "kanban", + }) + if err := r.reconcilePluginUI(server); err != nil { + t.Fatalf("create error = %v", err) + } + + // Remove UI spec entirely + server.Spec.UI = nil + if err := r.reconcilePluginUI(server); err != nil { + t.Fatalf("nil UI error = %v", err) + } + + plugins, _ := fakeClient.ListPlugins() + if len(plugins) != 0 { + t.Errorf("expected 0 plugins after nil UI, got %d", len(plugins)) + } +} + +func TestReconcilePluginUI_Update(t *testing.T) { + r, fakeClient := newTestReconciler(t) + + server := makeRemoteMCPServer("kagent", "kanban-mcp", "http://kanban-mcp:8080/mcp", &v1alpha2.PluginUISpec{ + Enabled: true, + PathPrefix: "kanban", + DisplayName: "Kanban Board", + Icon: "kanban", + Section: "AGENTS", + }) + + if err := r.reconcilePluginUI(server); err != nil { + t.Fatalf("create error = %v", err) + } + + // Update display name and icon + server.Spec.UI.DisplayName = "Updated Board" + server.Spec.UI.Icon = "layout-kanban" + if err := r.reconcilePluginUI(server); err != nil { + t.Fatalf("update error = %v", err) + } + + plugins, _ := fakeClient.ListPlugins() + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin after update, got %d", len(plugins)) + } + + p := plugins[0] + if p.DisplayName != "Updated Board" { + t.Errorf("DisplayName after update = %q, want %q", p.DisplayName, "Updated Board") + } + if p.Icon != "layout-kanban" { + t.Errorf("Icon after update = %q, want %q", p.Icon, "layout-kanban") + } +} diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 6c7870aa3..6b9146d18 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -389,6 +389,20 @@ func (a *adkApiTranslator) buildManifest( }, ) + // Inject Temporal and NATS env vars when Temporal is enabled. + if agent.Spec.Temporal != nil && agent.Spec.Temporal.Enabled { + sharedEnv = append(sharedEnv, + corev1.EnvVar{ + Name: env.TemporalHostAddr.Name(), + Value: env.TemporalHostAddr.Get(), + }, + corev1.EnvVar{ + Name: env.NATSAddr.Name(), + Value: env.NATSAddr.Get(), + }, + ) + } + var skills []string var gitRefs []v1alpha2.GitRepo var gitAuthSecretRef *corev1.LocalObjectReference @@ -650,6 +664,27 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al } } + // Translate Temporal configuration from CRD spec to runtime config. + if agent.Spec.Temporal != nil && agent.Spec.Temporal.Enabled { + tc := &adk.TemporalRuntimeConfig{ + Enabled: true, + Namespace: agent.Namespace, + TaskQueue: agent.Name, + } + if agent.Spec.Temporal.WorkflowTimeout != nil { + tc.WorkflowTimeout = agent.Spec.Temporal.WorkflowTimeout.Duration.String() + } + if agent.Spec.Temporal.RetryPolicy != nil { + if agent.Spec.Temporal.RetryPolicy.LLMMaxAttempts != nil { + tc.LLMMaxAttempts = int(*agent.Spec.Temporal.RetryPolicy.LLMMaxAttempts) + } + if agent.Spec.Temporal.RetryPolicy.ToolMaxAttempts != nil { + tc.ToolMaxAttempts = int(*agent.Spec.Temporal.RetryPolicy.ToolMaxAttempts) + } + } + cfg.Temporal = tc + } + for _, tool := range agent.Spec.Declarative.Tools { headers, err := tool.ResolveHeaders(ctx, a.kube, agent.Namespace) if err != nil { diff --git a/go/core/internal/controller/translator/agent/adk_api_translator_test.go b/go/core/internal/controller/translator/agent/adk_api_translator_test.go index fdffabf88..3a1950f8a 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator_test.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator_test.go @@ -1287,3 +1287,161 @@ func Test_AdkApiTranslator_ContextConfig(t *testing.T) { }) } } + +func Test_AdkApiTranslator_TemporalSpec(t *testing.T) { + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + namespace := "test-ns" + modelName := "ollama-model" + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + } + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: modelName, + Namespace: namespace, + }, + Spec: v1alpha2.ModelConfigSpec{ + Model: "llama2", + Provider: v1alpha2.ModelProviderOllama, + Ollama: &v1alpha2.OllamaConfig{ + Host: "http://ollama:11434", + }, + }, + } + + defaultModel := types.NamespacedName{Namespace: namespace, Name: modelName} + + tests := []struct { + name string + temporal *v1alpha2.TemporalSpec + wantConfig bool // expect TemporalRuntimeConfig in AgentConfig + wantEnvVars bool // expect TEMPORAL_HOST_ADDR and NATS_ADDR env vars + assertConfig func(t *testing.T, cfg *adk.AgentConfig) + }{ + { + name: "no temporal spec - config absent, no env vars", + temporal: nil, + wantConfig: false, + wantEnvVars: false, + }, + { + name: "temporal disabled - config absent, no env vars", + temporal: &v1alpha2.TemporalSpec{Enabled: false}, + wantConfig: false, + wantEnvVars: false, + }, + { + name: "temporal enabled with defaults", + temporal: &v1alpha2.TemporalSpec{Enabled: true}, + wantConfig: true, + wantEnvVars: true, + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.Temporal) + assert.True(t, cfg.Temporal.Enabled) + assert.Equal(t, "temporal-agent", cfg.Temporal.TaskQueue) + assert.Equal(t, "test-ns", cfg.Temporal.Namespace) + assert.Empty(t, cfg.Temporal.WorkflowTimeout) + assert.Zero(t, cfg.Temporal.LLMMaxAttempts) + assert.Zero(t, cfg.Temporal.ToolMaxAttempts) + }, + }, + { + name: "temporal enabled with all fields", + temporal: &v1alpha2.TemporalSpec{ + Enabled: true, + WorkflowTimeout: &metav1.Duration{Duration: 24 * 60 * 60 * 1e9}, // 24h + RetryPolicy: &v1alpha2.TemporalRetryPolicy{ + LLMMaxAttempts: ptr.To(int32(10)), + ToolMaxAttempts: ptr.To(int32(5)), + }, + }, + wantConfig: true, + wantEnvVars: true, + assertConfig: func(t *testing.T, cfg *adk.AgentConfig) { + require.NotNil(t, cfg.Temporal) + assert.True(t, cfg.Temporal.Enabled) + assert.Equal(t, "temporal-agent", cfg.Temporal.TaskQueue) + assert.Equal(t, "test-ns", cfg.Temporal.Namespace) + assert.Equal(t, "24h0m0s", cfg.Temporal.WorkflowTimeout) + assert.Equal(t, 10, cfg.Temporal.LLMMaxAttempts) + assert.Equal(t, 5, cfg.Temporal.ToolMaxAttempts) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "temporal-agent", + Namespace: namespace, + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Description: "Test Agent with Temporal", + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "You are a test agent", + ModelConfig: modelName, + }, + Temporal: tt.temporal, + }, + } + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ns, modelConfig, agent). + Build() + + trans := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") + outputs, err := trans.TranslateAgent(context.Background(), agent) + require.NoError(t, err) + require.NotNil(t, outputs) + require.NotNil(t, outputs.Config) + + // Check config + if tt.wantConfig { + require.NotNil(t, outputs.Config.Temporal) + } else { + assert.Nil(t, outputs.Config.Temporal) + } + + if tt.assertConfig != nil { + tt.assertConfig(t, outputs.Config) + } + + // Check env vars in deployment + var dep *appsv1.Deployment + for _, obj := range outputs.Manifest { + if d, ok := obj.(*appsv1.Deployment); ok { + dep = d + break + } + } + require.NotNil(t, dep, "Deployment not found in manifest") + + container := dep.Spec.Template.Spec.Containers[0] + hasTemporalEnv := false + hasNATSEnv := false + for _, e := range container.Env { + if e.Name == "TEMPORAL_HOST_ADDR" { + hasTemporalEnv = true + } + if e.Name == "NATS_ADDR" { + hasNATSEnv = true + } + } + + if tt.wantEnvVars { + assert.True(t, hasTemporalEnv, "Expected TEMPORAL_HOST_ADDR env var") + assert.True(t, hasNATSEnv, "Expected NATS_ADDR env var") + } else { + assert.False(t, hasTemporalEnv, "Did not expect TEMPORAL_HOST_ADDR env var") + assert.False(t, hasNATSEnv, "Did not expect NATS_ADDR env var") + } + }) + } +} diff --git a/go/core/internal/controller/workflowrun_controller.go b/go/core/internal/controller/workflowrun_controller.go new file mode 100644 index 000000000..00befe134 --- /dev/null +++ b/go/core/internal/controller/workflowrun_controller.go @@ -0,0 +1,280 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const ( + // workflowTaskQueue is the default task queue for DAG workflows. + workflowTaskQueue = "kagent-workflows" +) + +// WorkflowExecutionStatus describes the overall status of a Temporal workflow execution. +type WorkflowExecutionStatus string + +const ( + WorkflowExecutionRunning WorkflowExecutionStatus = "RUNNING" + WorkflowExecutionCompleted WorkflowExecutionStatus = "COMPLETED" + WorkflowExecutionFailed WorkflowExecutionStatus = "FAILED" + WorkflowExecutionCancelled WorkflowExecutionStatus = "CANCELLED" + WorkflowExecutionTerminated WorkflowExecutionStatus = "TERMINATED" + WorkflowExecutionTimedOut WorkflowExecutionStatus = "TIMED_OUT" +) + +// WorkflowDescription holds the result of describing a Temporal workflow execution. +type WorkflowDescription struct { + Status WorkflowExecutionStatus + // Error message if the workflow failed. + Error string +} + +// TemporalWorkflowClient abstracts Temporal client operations for testability. +type TemporalWorkflowClient interface { + // StartWorkflow starts a new Temporal workflow and returns the workflow ID. + StartWorkflow(ctx context.Context, workflowID, taskQueue string, plan *compiler.ExecutionPlan) error + // CancelWorkflow cancels a running Temporal workflow. + CancelWorkflow(ctx context.Context, workflowID string) error + // DescribeWorkflow returns the execution status of a Temporal workflow. + DescribeWorkflow(ctx context.Context, workflowID string) (*WorkflowDescription, error) + // QueryWorkflow queries a running workflow and unmarshals the result into valuePtr. + QueryWorkflow(ctx context.Context, workflowID, queryType string, valuePtr any) error +} + +// WorkflowRunController reconciles WorkflowRun objects. +// It validates params, snapshots the template, submits to Temporal, and handles cleanup. +type WorkflowRunController struct { + client.Client + Scheme *runtime.Scheme + Compiler *compiler.DAGCompiler + TemporalClient TemporalWorkflowClient +} + +// +kubebuilder:rbac:groups=kagent.dev,resources=workflowruns,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=kagent.dev,resources=workflowruns/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=kagent.dev,resources=workflowruns/finalizers,verbs=update +// +kubebuilder:rbac:groups=kagent.dev,resources=workflowtemplates,verbs=get;list;watch + +func (r *WorkflowRunController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var run v1alpha2.WorkflowRun + if err := r.Get(ctx, req.NamespacedName, &run); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Handle deletion. + if !run.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, &run) + } + + // Phase 1: Accept — resolve template, validate params, snapshot. + if !isConditionTrue(run.Status.Conditions, v1alpha2.WorkflowRunConditionAccepted) { + return r.handleAcceptance(ctx, &run) + } + + // Phase 2: Submit — compile and start Temporal workflow. + if run.Status.TemporalWorkflowID == "" { + return r.handleSubmission(ctx, &run) + } + + return ctrl.Result{}, nil +} + +// handleAcceptance resolves the template, validates params, snapshots the spec, and adds finalizer. +func (r *WorkflowRunController) handleAcceptance(ctx context.Context, run *v1alpha2.WorkflowRun) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Resolve template. + var template v1alpha2.WorkflowTemplate + templateKey := types.NamespacedName{ + Name: run.Spec.WorkflowTemplateRef, + Namespace: run.Namespace, + } + if err := r.Get(ctx, templateKey, &template); err != nil { + if client.IgnoreNotFound(err) == nil { + return r.setAcceptedFalse(ctx, run, "TemplateNotFound", + fmt.Sprintf("WorkflowTemplate %q not found", run.Spec.WorkflowTemplateRef)) + } + return ctrl.Result{}, fmt.Errorf("failed to get WorkflowTemplate: %w", err) + } + + // Check template is validated. + if !template.Status.Validated { + return r.setAcceptedFalse(ctx, run, "TemplateNotValidated", + fmt.Sprintf("WorkflowTemplate %q has not passed validation", run.Spec.WorkflowTemplateRef)) + } + + // Validate params against template spec. + paramMap := paramsToMap(run.Spec.Params) + if _, err := r.Compiler.Compile(&template.Spec, paramMap, "validate", "validate"); err != nil { + return r.setAcceptedFalse(ctx, run, "InvalidParams", err.Error()) + } + + logger.Info("Accepting WorkflowRun", "name", run.Name, "template", run.Spec.WorkflowTemplateRef) + + // Add finalizer. + if !controllerutil.ContainsFinalizer(run, v1alpha2.WorkflowRunFinalizer) { + controllerutil.AddFinalizer(run, v1alpha2.WorkflowRunFinalizer) + if err := r.Update(ctx, run); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to add finalizer: %w", err) + } + } + + // Snapshot template spec. + run.Status.ResolvedSpec = template.Spec.DeepCopy() + run.Status.TemplateGeneration = template.Generation + run.Status.Phase = v1alpha2.WorkflowRunPhasePending + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionAccepted, + Status: metav1.ConditionTrue, + Reason: "Accepted", + Message: "Template resolved and parameters validated", + ObservedGeneration: run.Generation, + }) + + if err := r.Status().Update(ctx, run); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update WorkflowRun status: %w", err) + } + + return ctrl.Result{Requeue: true}, nil +} + +// handleSubmission compiles the execution plan and starts the Temporal workflow. +func (r *WorkflowRunController) handleSubmission(ctx context.Context, run *v1alpha2.WorkflowRun) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + workflowID := fmt.Sprintf("wf-%s-%s-%s", run.Namespace, run.Spec.WorkflowTemplateRef, run.Name) + paramMap := paramsToMap(run.Spec.Params) + + plan, err := r.Compiler.Compile(run.Status.ResolvedSpec, paramMap, workflowID, workflowTaskQueue) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to compile execution plan: %w", err) + } + + logger.Info("Starting Temporal workflow", "workflowID", workflowID, "steps", len(plan.Steps)) + + if err := r.TemporalClient.StartWorkflow(ctx, workflowID, workflowTaskQueue, plan); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to start Temporal workflow: %w", err) + } + + now := metav1.Now() + run.Status.TemporalWorkflowID = workflowID + run.Status.Phase = v1alpha2.WorkflowRunPhaseRunning + run.Status.StartTime = &now + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionRunning, + Status: metav1.ConditionTrue, + Reason: "WorkflowStarted", + Message: fmt.Sprintf("Temporal workflow %s started", workflowID), + ObservedGeneration: run.Generation, + }) + + if err := r.Status().Update(ctx, run); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update WorkflowRun status: %w", err) + } + + return ctrl.Result{}, nil +} + +// handleDeletion cancels the Temporal workflow and removes the finalizer. +func (r *WorkflowRunController) handleDeletion(ctx context.Context, run *v1alpha2.WorkflowRun) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(run, v1alpha2.WorkflowRunFinalizer) { + return ctrl.Result{}, nil + } + + // Cancel Temporal workflow if one was started. + if run.Status.TemporalWorkflowID != "" && r.TemporalClient != nil { + logger.Info("Cancelling Temporal workflow", "workflowID", run.Status.TemporalWorkflowID) + if err := r.TemporalClient.CancelWorkflow(ctx, run.Status.TemporalWorkflowID); err != nil { + logger.Error(err, "failed to cancel Temporal workflow, proceeding with cleanup", + "workflowID", run.Status.TemporalWorkflowID) + } + } + + controllerutil.RemoveFinalizer(run, v1alpha2.WorkflowRunFinalizer) + if err := r.Update(ctx, run); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) + } + + return ctrl.Result{}, nil +} + +// setAcceptedFalse sets the Accepted condition to False and updates status. +func (r *WorkflowRunController) setAcceptedFalse(ctx context.Context, run *v1alpha2.WorkflowRun, reason, message string) (ctrl.Result, error) { + run.Status.Phase = v1alpha2.WorkflowRunPhaseFailed + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionAccepted, + Status: metav1.ConditionFalse, + Reason: reason, + Message: message, + ObservedGeneration: run.Generation, + }) + + if err := r.Status().Update(ctx, run); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update WorkflowRun status: %w", err) + } + return ctrl.Result{}, nil +} + +// isConditionTrue checks if a condition with the given type is True. +func isConditionTrue(conditions []metav1.Condition, condType string) bool { + for _, c := range conditions { + if c.Type == condType && c.Status == metav1.ConditionTrue { + return true + } + } + return false +} + +// paramsToMap converts a slice of Params to a map. +func paramsToMap(params []v1alpha2.Param) map[string]string { + m := make(map[string]string, len(params)) + for _, p := range params { + m[p.Name] = p.Value + } + return m +} + +// SetupWithManager sets up the controller with the Manager. +func (r *WorkflowRunController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + WithOptions(controller.Options{ + NeedLeaderElection: ptr.To(true), + }). + For(&v1alpha2.WorkflowRun{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Named("workflowrun"). + Complete(r) +} diff --git a/go/core/internal/controller/workflowrun_controller_test.go b/go/core/internal/controller/workflowrun_controller_test.go new file mode 100644 index 000000000..90a2d2da5 --- /dev/null +++ b/go/core/internal/controller/workflowrun_controller_test.go @@ -0,0 +1,634 @@ +package controller + +import ( + "context" + "fmt" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// mockTemporalClient implements TemporalWorkflowClient for testing. +type mockTemporalClient struct { + startCalled bool + cancelCalled bool + startErr error + cancelErr error + lastPlan *compiler.ExecutionPlan + + describeResult *WorkflowDescription + describeErr error + queryResult interface{} + queryErr error +} + +func (m *mockTemporalClient) StartWorkflow(_ context.Context, _, _ string, plan *compiler.ExecutionPlan) error { + m.startCalled = true + m.lastPlan = plan + return m.startErr +} + +func (m *mockTemporalClient) CancelWorkflow(_ context.Context, _ string) error { + m.cancelCalled = true + return m.cancelErr +} + +func (m *mockTemporalClient) DescribeWorkflow(_ context.Context, _ string) (*WorkflowDescription, error) { + return m.describeResult, m.describeErr +} + +func (m *mockTemporalClient) QueryWorkflow(_ context.Context, _, _ string, _ any) error { + return m.queryErr +} + +// validTemplate returns a validated WorkflowTemplate for testing. +func validTemplate() *v1alpha2.WorkflowTemplate { + return &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-template", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "step-b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-a"}}, + }, + }, + Status: v1alpha2.WorkflowTemplateStatus{ + Validated: true, + ObservedGeneration: 1, + }, + } +} + +// templateWithParams returns a validated template with required params. +func templateWithParams() *v1alpha2.WorkflowTemplate { + t := validTemplate() + t.Name = "param-template" + t.Spec.Params = []v1alpha2.ParamSpec{ + {Name: "env", Type: v1alpha2.ParamTypeString}, + } + return t +} + +func TestWorkflowRunController_TemplateNotFound(t *testing.T) { + s := newTestScheme() + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "nonexistent-template", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: &mockTemporalClient{}, + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get updated run: %v", err) + } + + cond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionAccepted) + if cond == nil { + t.Fatal("Accepted condition not found") + } + if cond.Status != metav1.ConditionFalse { + t.Errorf("Accepted status = %v, want False", cond.Status) + } + if cond.Reason != "TemplateNotFound" { + t.Errorf("Accepted reason = %q, want TemplateNotFound", cond.Reason) + } + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseFailed { + t.Errorf("Phase = %q, want Failed", updated.Status.Phase) + } +} + +func TestWorkflowRunController_TemplateNotValidated(t *testing.T) { + s := newTestScheme() + template := validTemplate() + template.Status.Validated = false + + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(template, run). + WithStatusSubresource(template, run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: &mockTemporalClient{}, + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get updated run: %v", err) + } + + cond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionAccepted) + if cond == nil { + t.Fatal("Accepted condition not found") + } + if cond.Reason != "TemplateNotValidated" { + t.Errorf("Accepted reason = %q, want TemplateNotValidated", cond.Reason) + } +} + +func TestWorkflowRunController_MissingRequiredParam(t *testing.T) { + s := newTestScheme() + template := templateWithParams() + + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "param-template", + // Missing required "env" param. + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(template, run). + WithStatusSubresource(template, run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: &mockTemporalClient{}, + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get updated run: %v", err) + } + + cond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionAccepted) + if cond == nil { + t.Fatal("Accepted condition not found") + } + if cond.Status != metav1.ConditionFalse { + t.Errorf("Accepted status = %v, want False", cond.Status) + } + if cond.Reason != "InvalidParams" { + t.Errorf("Accepted reason = %q, want InvalidParams", cond.Reason) + } +} + +func TestWorkflowRunController_ValidRun(t *testing.T) { + s := newTestScheme() + template := validTemplate() + tc := &mockTemporalClient{} + + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(template, run). + WithStatusSubresource(template, run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: tc, + } + + // First reconcile: acceptance phase — should snapshot and requeue. + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile(accept) error = %v", err) + } + if !result.Requeue { + t.Error("expected requeue after acceptance") + } + + var accepted v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &accepted); err != nil { + t.Fatalf("failed to get accepted run: %v", err) + } + + if accepted.Status.ResolvedSpec == nil { + t.Fatal("ResolvedSpec should be set after acceptance") + } + if accepted.Status.TemplateGeneration != 1 { + t.Errorf("TemplateGeneration = %d, want 1", accepted.Status.TemplateGeneration) + } + if accepted.Status.Phase != v1alpha2.WorkflowRunPhasePending { + t.Errorf("Phase = %q, want Pending", accepted.Status.Phase) + } + + cond := findCondition(accepted.Status.Conditions, v1alpha2.WorkflowRunConditionAccepted) + if cond == nil || cond.Status != metav1.ConditionTrue { + t.Error("Accepted condition should be True") + } + + // Second reconcile: submission phase — should start Temporal workflow. + result, err = r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile(submit) error = %v", err) + } + if result.Requeue { + t.Error("should not requeue after submission") + } + + if !tc.startCalled { + t.Error("Temporal StartWorkflow should have been called") + } + + var submitted v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &submitted); err != nil { + t.Fatalf("failed to get submitted run: %v", err) + } + + expectedWFID := "wf-default-my-template-test-run" + if submitted.Status.TemporalWorkflowID != expectedWFID { + t.Errorf("TemporalWorkflowID = %q, want %q", submitted.Status.TemporalWorkflowID, expectedWFID) + } + if submitted.Status.Phase != v1alpha2.WorkflowRunPhaseRunning { + t.Errorf("Phase = %q, want Running", submitted.Status.Phase) + } + if submitted.Status.StartTime == nil { + t.Error("StartTime should be set") + } + + runningCond := findCondition(submitted.Status.Conditions, v1alpha2.WorkflowRunConditionRunning) + if runningCond == nil || runningCond.Status != metav1.ConditionTrue { + t.Error("Running condition should be True") + } +} + +func TestWorkflowRunController_IdempotentReconciliation(t *testing.T) { + s := newTestScheme() + template := validTemplate() + tc := &mockTemporalClient{} + + // Run that is already submitted. + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + Status: v1alpha2.WorkflowRunStatus{ + Phase: v1alpha2.WorkflowRunPhaseRunning, + TemporalWorkflowID: "wf-default-my-template-test-run", + Conditions: []metav1.Condition{ + { + Type: v1alpha2.WorkflowRunConditionAccepted, + Status: metav1.ConditionTrue, + Reason: "Accepted", + }, + { + Type: v1alpha2.WorkflowRunConditionRunning, + Status: metav1.ConditionTrue, + Reason: "WorkflowStarted", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(template, run). + WithStatusSubresource(template, run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: tc, + } + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.Requeue { + t.Error("should not requeue for already-submitted run") + } + if tc.startCalled { + t.Error("StartWorkflow should NOT be called for already-submitted run") + } +} + +func TestWorkflowRunController_Deletion(t *testing.T) { + s := newTestScheme() + tc := &mockTemporalClient{} + now := metav1.Now() + + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + DeletionTimestamp: &now, + Finalizers: []string{v1alpha2.WorkflowRunFinalizer}, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + Status: v1alpha2.WorkflowRunStatus{ + TemporalWorkflowID: "wf-default-my-template-test-run", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: tc, + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + if !tc.cancelCalled { + t.Error("CancelWorkflow should have been called") + } + + // After finalizer removal with DeletionTimestamp set, the fake client + // deletes the object. Verify the object is gone (confirming finalizer was removed). + var updated v1alpha2.WorkflowRun + err = fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated) + if err == nil { + // Object still exists — check finalizer was removed. + for _, f := range updated.Finalizers { + if f == v1alpha2.WorkflowRunFinalizer { + t.Error("finalizer should have been removed") + } + } + } + // If err is NotFound, that's expected — the fake client deleted the object + // after the finalizer was removed. +} + +func TestWorkflowRunController_DeletionWithoutWorkflowID(t *testing.T) { + s := newTestScheme() + tc := &mockTemporalClient{} + now := metav1.Now() + + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + DeletionTimestamp: &now, + Finalizers: []string{v1alpha2.WorkflowRunFinalizer}, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: tc, + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + + if tc.cancelCalled { + t.Error("CancelWorkflow should NOT be called when no workflow ID exists") + } +} + +func TestWorkflowRunController_TemporalStartFailure(t *testing.T) { + s := newTestScheme() + template := validTemplate() + tc := &mockTemporalClient{startErr: fmt.Errorf("temporal unavailable")} + + // Pre-accepted run ready for submission. + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + Status: v1alpha2.WorkflowRunStatus{ + Phase: v1alpha2.WorkflowRunPhasePending, + ResolvedSpec: &v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + }, + Conditions: []metav1.Condition{ + { + Type: v1alpha2.WorkflowRunConditionAccepted, + Status: metav1.ConditionTrue, + Reason: "Accepted", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(template, run). + WithStatusSubresource(template, run). + Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: tc, + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "test-run", Namespace: "default"}, + }) + if err == nil { + t.Fatal("Reconcile() should return error when Temporal start fails") + } +} + +func TestWorkflowRunController_NotFoundIgnored(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + + r := &WorkflowRunController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + TemporalClient: &mockTemporalClient{}, + } + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "nonexistent", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v, want nil for not found", err) + } + if result.Requeue { + t.Error("should not requeue for not found") + } +} + +func TestParamsToMap(t *testing.T) { + params := []v1alpha2.Param{ + {Name: "env", Value: "prod"}, + {Name: "region", Value: "us-east-1"}, + } + m := paramsToMap(params) + if m["env"] != "prod" { + t.Errorf("env = %q, want prod", m["env"]) + } + if m["region"] != "us-east-1" { + t.Errorf("region = %q, want us-east-1", m["region"]) + } +} + +func TestIsConditionTrue(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + condType string + want bool + }{ + { + name: "empty conditions", + conditions: nil, + condType: "Accepted", + want: false, + }, + { + name: "condition true", + conditions: []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionTrue}, + }, + condType: "Accepted", + want: true, + }, + { + name: "condition false", + conditions: []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionFalse}, + }, + condType: "Accepted", + want: false, + }, + { + name: "different condition type", + conditions: []metav1.Condition{ + {Type: "Running", Status: metav1.ConditionTrue}, + }, + condType: "Accepted", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isConditionTrue(tt.conditions, tt.condType) + if got != tt.want { + t.Errorf("isConditionTrue() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go/core/internal/controller/workflowrun_retention.go b/go/core/internal/controller/workflowrun_retention.go new file mode 100644 index 000000000..40ae48222 --- /dev/null +++ b/go/core/internal/controller/workflowrun_retention.go @@ -0,0 +1,245 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + // defaultRetentionInterval is the default polling interval for retention cleanup. + defaultRetentionInterval = 60 * time.Second +) + +// WorkflowRunRetentionController periodically cleans up old WorkflowRuns +// based on retention policies (history limits) and TTL settings. +type WorkflowRunRetentionController struct { + K8sClient client.Client + Interval time.Duration +} + +// Start begins the retention cleanup loop. It blocks until the context is cancelled. +func (r *WorkflowRunRetentionController) Start(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("retention-controller") + interval := r.Interval + if interval == 0 { + interval = defaultRetentionInterval + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + logger.Info("Retention controller started", "interval", interval) + + for { + select { + case <-ctx.Done(): + logger.Info("Retention controller stopped") + return nil + case <-ticker.C: + if err := r.cleanup(ctx); err != nil { + logger.Error(err, "retention cleanup cycle failed") + } + } + } +} + +// NeedLeaderElection implements manager.LeaderElectionRunnable so the retention +// controller only runs on the leader. +func (r *WorkflowRunRetentionController) NeedLeaderElection() bool { + return true +} + +// cleanup performs a single retention cleanup cycle. +func (r *WorkflowRunRetentionController) cleanup(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("retention-controller") + + // Enforce TTL-based cleanup. + if err := r.cleanupTTL(ctx); err != nil { + logger.Error(err, "TTL cleanup failed") + } + + // Enforce history-limit-based cleanup. + if err := r.cleanupHistoryLimits(ctx); err != nil { + logger.Error(err, "history limit cleanup failed") + } + + return nil +} + +// cleanupTTL deletes completed WorkflowRuns whose TTL has expired. +func (r *WorkflowRunRetentionController) cleanupTTL(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("retention-controller") + + var runList v1alpha2.WorkflowRunList + if err := r.K8sClient.List(ctx, &runList); err != nil { + return fmt.Errorf("failed to list WorkflowRuns: %w", err) + } + + now := time.Now() + for i := range runList.Items { + run := &runList.Items[i] + + // Skip runs without TTL, non-terminal runs, or runs without completion time. + if run.Spec.TTLSecondsAfterFinished == nil || !isTerminalPhase(run.Status.Phase) || run.Status.CompletionTime == nil { + continue + } + + ttl := time.Duration(*run.Spec.TTLSecondsAfterFinished) * time.Second + expiry := run.Status.CompletionTime.Time.Add(ttl) + if now.After(expiry) { + logger.Info("Deleting WorkflowRun due to TTL expiry", + "name", run.Name, "namespace", run.Namespace, + "completionTime", run.Status.CompletionTime.Time, + "ttl", ttl) + if err := r.K8sClient.Delete(ctx, run); err != nil { + logger.Error(err, "failed to delete expired WorkflowRun", + "name", run.Name, "namespace", run.Namespace) + } + } + } + + return nil +} + +// cleanupHistoryLimits enforces retention history limits from WorkflowTemplates. +func (r *WorkflowRunRetentionController) cleanupHistoryLimits(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("retention-controller") + + // List all templates with retention policies. + var templateList v1alpha2.WorkflowTemplateList + if err := r.K8sClient.List(ctx, &templateList); err != nil { + return fmt.Errorf("failed to list WorkflowTemplates: %w", err) + } + + for i := range templateList.Items { + tmpl := &templateList.Items[i] + if tmpl.Spec.Retention == nil { + continue + } + + if err := r.enforceHistoryLimit(ctx, tmpl); err != nil { + logger.Error(err, "failed to enforce history limit", + "template", tmpl.Name, "namespace", tmpl.Namespace) + } + } + + return nil +} + +// enforceHistoryLimit deletes the oldest completed runs beyond the retention limits for a template. +func (r *WorkflowRunRetentionController) enforceHistoryLimit(ctx context.Context, tmpl *v1alpha2.WorkflowTemplate) error { + logger := log.FromContext(ctx).WithName("retention-controller") + + // List all runs for this template. + var runList v1alpha2.WorkflowRunList + if err := r.K8sClient.List(ctx, &runList, client.InNamespace(tmpl.Namespace)); err != nil { + return fmt.Errorf("failed to list WorkflowRuns: %w", err) + } + + // Separate into succeeded and failed, filtering for this template. + var succeeded, failed []*v1alpha2.WorkflowRun + for i := range runList.Items { + run := &runList.Items[i] + if run.Spec.WorkflowTemplateRef != tmpl.Name { + continue + } + + switch run.Status.Phase { + case v1alpha2.WorkflowRunPhaseSucceeded: + succeeded = append(succeeded, run) + case v1alpha2.WorkflowRunPhaseFailed: + failed = append(failed, run) + } + } + + // Sort by completion time ascending (oldest first). + sortByCompletionTime(succeeded) + sortByCompletionTime(failed) + + // Enforce successful runs limit. + if tmpl.Spec.Retention.SuccessfulRunsHistoryLimit != nil { + limit := int(*tmpl.Spec.Retention.SuccessfulRunsHistoryLimit) + if len(succeeded) > limit { + toDelete := succeeded[:len(succeeded)-limit] + for _, run := range toDelete { + logger.Info("Deleting WorkflowRun due to successful history limit", + "name", run.Name, "namespace", run.Namespace, + "template", tmpl.Name, "limit", limit) + if err := r.K8sClient.Delete(ctx, run); err != nil { + logger.Error(err, "failed to delete WorkflowRun", + "name", run.Name, "namespace", run.Namespace) + } + } + } + } + + // Enforce failed runs limit. + if tmpl.Spec.Retention.FailedRunsHistoryLimit != nil { + limit := int(*tmpl.Spec.Retention.FailedRunsHistoryLimit) + if len(failed) > limit { + toDelete := failed[:len(failed)-limit] + for _, run := range toDelete { + logger.Info("Deleting WorkflowRun due to failed history limit", + "name", run.Name, "namespace", run.Namespace, + "template", tmpl.Name, "limit", limit) + if err := r.K8sClient.Delete(ctx, run); err != nil { + logger.Error(err, "failed to delete WorkflowRun", + "name", run.Name, "namespace", run.Namespace) + } + } + } + } + + return nil +} + +// sortByCompletionTime sorts runs by completion time ascending (oldest first). +// Runs without completion time are placed first (treated as oldest). +func sortByCompletionTime(runs []*v1alpha2.WorkflowRun) { + sort.Slice(runs, func(i, j int) bool { + ti := runs[i].Status.CompletionTime + tj := runs[j].Status.CompletionTime + if ti == nil && tj == nil { + return false + } + if ti == nil { + return true + } + if tj == nil { + return false + } + return ti.Time.Before(tj.Time) + }) +} + +// isTerminalPhase returns true if the phase represents a completed workflow. +func isTerminalPhase(phase string) bool { + switch phase { + case v1alpha2.WorkflowRunPhaseSucceeded, v1alpha2.WorkflowRunPhaseFailed, v1alpha2.WorkflowRunPhaseCancelled: + return true + } + return false +} diff --git a/go/core/internal/controller/workflowrun_retention_test.go b/go/core/internal/controller/workflowrun_retention_test.go new file mode 100644 index 000000000..ed835d216 --- /dev/null +++ b/go/core/internal/controller/workflowrun_retention_test.go @@ -0,0 +1,329 @@ +package controller + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = v1alpha2.AddToScheme(s) + return s +} + +func makeRun(name, namespace, templateRef, phase string, completionTime *metav1.Time, ttl *int32) *v1alpha2.WorkflowRun { + return &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: templateRef, + TTLSecondsAfterFinished: ttl, + }, + Status: v1alpha2.WorkflowRunStatus{ + Phase: phase, + CompletionTime: completionTime, + }, + } +} + +func makeTemplate(name, namespace string, successLimit, failLimit *int32) *v1alpha2.WorkflowTemplate { + var retention *v1alpha2.RetentionPolicy + if successLimit != nil || failLimit != nil { + retention = &v1alpha2.RetentionPolicy{ + SuccessfulRunsHistoryLimit: successLimit, + FailedRunsHistoryLimit: failLimit, + } + } + return &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step1", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + Retention: retention, + }, + } +} + +func timeAt(minutesAgo int) *metav1.Time { + t := metav1.NewTime(time.Now().Add(-time.Duration(minutesAgo) * time.Minute)) + return &t +} + +func TestRetentionTTL(t *testing.T) { + tests := []struct { + name string + runs []*v1alpha2.WorkflowRun + wantDeleted []string + wantRetained []string + }{ + { + name: "TTL expired - run deleted", + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), ptr.To(int32(60))), + }, + wantDeleted: []string{"run1"}, + wantRetained: nil, + }, + { + name: "TTL not expired - run retained", + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(1), ptr.To(int32(600))), + }, + wantDeleted: nil, + wantRetained: []string{"run1"}, + }, + { + name: "No TTL set - run retained", + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + }, + wantDeleted: nil, + wantRetained: []string{"run1"}, + }, + { + name: "Running run with TTL - not deleted", + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseRunning, nil, ptr.To(int32(60))), + }, + wantDeleted: nil, + wantRetained: []string{"run1"}, + }, + { + name: "Failed run with expired TTL - deleted", + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(10), ptr.To(int32(60))), + }, + wantDeleted: []string{"run1"}, + wantRetained: nil, + }, + { + name: "Mixed - some expired some not", + runs: []*v1alpha2.WorkflowRun{ + makeRun("expired", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), ptr.To(int32(60))), + makeRun("active", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(1), ptr.To(int32(600))), + }, + wantDeleted: []string{"expired"}, + wantRetained: []string{"active"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := newScheme() + objs := make([]client.Object, len(tt.runs)) + for i, r := range tt.runs { + objs[i] = r + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).WithStatusSubresource(&v1alpha2.WorkflowRun{}).Build() + + rc := &WorkflowRunRetentionController{K8sClient: k8sClient} + err := rc.cleanupTTL(context.Background()) + if err != nil { + t.Fatalf("cleanupTTL() error = %v", err) + } + + for _, name := range tt.wantDeleted { + run := &v1alpha2.WorkflowRun{} + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: "default"}, run) + if err == nil { + t.Errorf("expected run %q to be deleted, but it still exists", name) + } + } + + for _, name := range tt.wantRetained { + run := &v1alpha2.WorkflowRun{} + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: "default"}, run) + if err != nil { + t.Errorf("expected run %q to be retained, but got error: %v", name, err) + } + } + }) + } +} + +func TestRetentionHistoryLimits(t *testing.T) { + tests := []struct { + name string + template *v1alpha2.WorkflowTemplate + runs []*v1alpha2.WorkflowRun + wantDeleted []string + wantRetained []string + }{ + { + name: "Successful limit of 3 with 5 runs - 2 oldest deleted", + template: makeTemplate("tmpl", "default", ptr.To(int32(3)), nil), + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(50), nil), + makeRun("run2", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(40), nil), + makeRun("run3", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(30), nil), + makeRun("run4", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(20), nil), + makeRun("run5", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + }, + wantDeleted: []string{"run1", "run2"}, + wantRetained: []string{"run3", "run4", "run5"}, + }, + { + name: "Failed limit of 2 with 4 runs - 2 oldest deleted", + template: makeTemplate("tmpl", "default", nil, ptr.To(int32(2))), + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(40), nil), + makeRun("run2", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(30), nil), + makeRun("run3", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(20), nil), + makeRun("run4", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(10), nil), + }, + wantDeleted: []string{"run1", "run2"}, + wantRetained: []string{"run3", "run4"}, + }, + { + name: "Under limit - no deletions", + template: makeTemplate("tmpl", "default", ptr.To(int32(5)), nil), + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(20), nil), + makeRun("run2", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + }, + wantDeleted: nil, + wantRetained: []string{"run1", "run2"}, + }, + { + name: "No retention policy - no deletions", + template: makeTemplate("tmpl", "default", nil, nil), + runs: []*v1alpha2.WorkflowRun{ + makeRun("run1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(20), nil), + makeRun("run2", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + }, + wantDeleted: nil, + wantRetained: []string{"run1", "run2"}, + }, + { + name: "Both limits enforced independently", + template: makeTemplate("tmpl", "default", ptr.To(int32(1)), ptr.To(int32(1))), + runs: []*v1alpha2.WorkflowRun{ + makeRun("s1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(30), nil), + makeRun("s2", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + makeRun("f1", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(30), nil), + makeRun("f2", "default", "tmpl", v1alpha2.WorkflowRunPhaseFailed, timeAt(10), nil), + }, + wantDeleted: []string{"s1", "f1"}, + wantRetained: []string{"s2", "f2"}, + }, + { + name: "Running runs not affected by limits", + template: makeTemplate("tmpl", "default", ptr.To(int32(1)), nil), + runs: []*v1alpha2.WorkflowRun{ + makeRun("s1", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(20), nil), + makeRun("s2", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + makeRun("r1", "default", "tmpl", v1alpha2.WorkflowRunPhaseRunning, nil, nil), + }, + wantDeleted: []string{"s1"}, + wantRetained: []string{"s2", "r1"}, + }, + { + name: "Runs from different template not affected", + template: makeTemplate("tmpl-a", "default", ptr.To(int32(1)), nil), + runs: []*v1alpha2.WorkflowRun{ + makeRun("a1", "default", "tmpl-a", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(20), nil), + makeRun("a2", "default", "tmpl-a", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(10), nil), + makeRun("b1", "default", "tmpl-b", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(20), nil), + }, + wantDeleted: []string{"a1"}, + wantRetained: []string{"a2", "b1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := newScheme() + objs := []client.Object{tt.template} + for _, r := range tt.runs { + objs = append(objs, r) + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).WithStatusSubresource(&v1alpha2.WorkflowRun{}).Build() + + rc := &WorkflowRunRetentionController{K8sClient: k8sClient} + err := rc.cleanupHistoryLimits(context.Background()) + if err != nil { + t.Fatalf("cleanupHistoryLimits() error = %v", err) + } + + for _, name := range tt.wantDeleted { + run := &v1alpha2.WorkflowRun{} + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: "default"}, run) + if err == nil { + t.Errorf("expected run %q to be deleted, but it still exists", name) + } + } + + for _, name := range tt.wantRetained { + run := &v1alpha2.WorkflowRun{} + err := k8sClient.Get(context.Background(), types.NamespacedName{Name: name, Namespace: "default"}, run) + if err != nil { + t.Errorf("expected run %q to be retained, but got error: %v", name, err) + } + } + }) + } +} + +func TestRetentionSortByCompletionTime(t *testing.T) { + runs := []*v1alpha2.WorkflowRun{ + makeRun("newest", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(1), nil), + makeRun("oldest", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(30), nil), + makeRun("middle", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, timeAt(15), nil), + makeRun("no-time", "default", "tmpl", v1alpha2.WorkflowRunPhaseSucceeded, nil, nil), + } + + sortByCompletionTime(runs) + + expected := []string{"no-time", "oldest", "middle", "newest"} + for i, name := range expected { + if runs[i].Name != name { + t.Errorf("position %d: got %q, want %q", i, runs[i].Name, name) + } + } +} + +func TestRetentionIsTerminalPhase(t *testing.T) { + tests := []struct { + phase string + want bool + }{ + {v1alpha2.WorkflowRunPhaseSucceeded, true}, + {v1alpha2.WorkflowRunPhaseFailed, true}, + {v1alpha2.WorkflowRunPhaseCancelled, true}, + {v1alpha2.WorkflowRunPhaseRunning, false}, + {v1alpha2.WorkflowRunPhasePending, false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("phase=%s", tt.phase), func(t *testing.T) { + if got := isTerminalPhase(tt.phase); got != tt.want { + t.Errorf("isTerminalPhase(%q) = %v, want %v", tt.phase, got, tt.want) + } + }) + } +} + +func TestRetentionNeedLeaderElection(t *testing.T) { + rc := &WorkflowRunRetentionController{} + if !rc.NeedLeaderElection() { + t.Error("NeedLeaderElection() should return true") + } +} diff --git a/go/core/internal/controller/workflowrun_status_syncer.go b/go/core/internal/controller/workflowrun_status_syncer.go new file mode 100644 index 000000000..4ed89f0fa --- /dev/null +++ b/go/core/internal/controller/workflowrun_status_syncer.go @@ -0,0 +1,226 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + workflow "github.com/kagent-dev/kagent/go/core/internal/temporal/workflow" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + // defaultSyncInterval is the default polling interval for status syncing. + defaultSyncInterval = 5 * time.Second +) + +// WorkflowRunStatusSyncer polls Temporal and updates WorkflowRun status. +type WorkflowRunStatusSyncer struct { + K8sClient client.Client + TemporalClient TemporalWorkflowClient + Interval time.Duration +} + +// Start begins the status sync loop. It blocks until the context is cancelled. +func (s *WorkflowRunStatusSyncer) Start(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("status-syncer") + interval := s.Interval + if interval == 0 { + interval = defaultSyncInterval + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + logger.Info("Status syncer started", "interval", interval) + + for { + select { + case <-ctx.Done(): + logger.Info("Status syncer stopped") + return nil + case <-ticker.C: + if err := s.syncAll(ctx); err != nil { + logger.Error(err, "sync cycle failed") + } + } + } +} + +// syncAll finds all running WorkflowRuns and syncs their status from Temporal. +func (s *WorkflowRunStatusSyncer) syncAll(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("status-syncer") + + var runList v1alpha2.WorkflowRunList + if err := s.K8sClient.List(ctx, &runList); err != nil { + return fmt.Errorf("failed to list WorkflowRuns: %w", err) + } + + for i := range runList.Items { + run := &runList.Items[i] + + // Only sync runs that are in Running phase with a Temporal workflow ID. + if run.Status.Phase != v1alpha2.WorkflowRunPhaseRunning || run.Status.TemporalWorkflowID == "" { + continue + } + + if err := s.syncOne(ctx, run); err != nil { + logger.Error(err, "failed to sync WorkflowRun", + "name", run.Name, "namespace", run.Namespace, + "workflowID", run.Status.TemporalWorkflowID) + } + } + + return nil +} + +// syncOne syncs a single WorkflowRun's status from Temporal. +func (s *WorkflowRunStatusSyncer) syncOne(ctx context.Context, run *v1alpha2.WorkflowRun) error { + workflowID := run.Status.TemporalWorkflowID + + // Describe workflow execution for overall status. + desc, err := s.TemporalClient.DescribeWorkflow(ctx, workflowID) + if err != nil { + return fmt.Errorf("failed to describe workflow %s: %w", workflowID, err) + } + + // Query per-step statuses from the DAG workflow. + var stepResults []workflow.StepResult + if desc.Status == WorkflowExecutionRunning { + if err := s.TemporalClient.QueryWorkflow(ctx, workflowID, workflow.DAGStatusQueryType, &stepResults); err != nil { + // Query failure is non-fatal for running workflows — skip step sync. + log.FromContext(ctx).WithName("status-syncer").V(1).Info( + "failed to query step status, skipping step sync", + "workflowID", workflowID, "error", err) + } + } + + // Build updated step statuses. + updated := false + if len(stepResults) > 0 { + newSteps := make([]v1alpha2.StepStatus, len(stepResults)) + for i, sr := range stepResults { + newSteps[i] = v1alpha2.StepStatus{ + Name: sr.Name, + Phase: v1alpha2.StepPhase(sr.Phase), + Message: sr.Error, + Retries: sr.Retries, + } + } + if !stepStatusesEqual(run.Status.Steps, newSteps) { + run.Status.Steps = newSteps + updated = true + } + } + + // Handle terminal states. + switch desc.Status { + case WorkflowExecutionCompleted: + // Query final step statuses for completed workflows. + // For completed workflows, we get the result from the workflow output, not query. + now := metav1.Now() + run.Status.Phase = v1alpha2.WorkflowRunPhaseSucceeded + run.Status.CompletionTime = &now + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionRunning, + Status: metav1.ConditionFalse, + Reason: "WorkflowCompleted", + Message: "Temporal workflow completed successfully", + ObservedGeneration: run.Generation, + }) + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionSucceeded, + Status: metav1.ConditionTrue, + Reason: "Succeeded", + Message: "Workflow completed successfully", + ObservedGeneration: run.Generation, + }) + updated = true + + case WorkflowExecutionFailed: + now := metav1.Now() + run.Status.Phase = v1alpha2.WorkflowRunPhaseFailed + run.Status.CompletionTime = &now + message := "Temporal workflow failed" + if desc.Error != "" { + message = fmt.Sprintf("Temporal workflow failed: %s", desc.Error) + } + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionRunning, + Status: metav1.ConditionFalse, + Reason: "WorkflowFailed", + Message: message, + ObservedGeneration: run.Generation, + }) + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionSucceeded, + Status: metav1.ConditionFalse, + Reason: "Failed", + Message: message, + ObservedGeneration: run.Generation, + }) + updated = true + + case WorkflowExecutionCancelled, WorkflowExecutionTerminated, WorkflowExecutionTimedOut: + now := metav1.Now() + run.Status.Phase = v1alpha2.WorkflowRunPhaseCancelled + run.Status.CompletionTime = &now + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: v1alpha2.WorkflowRunConditionRunning, + Status: metav1.ConditionFalse, + Reason: "Workflow" + string(desc.Status), + Message: fmt.Sprintf("Temporal workflow %s", desc.Status), + ObservedGeneration: run.Generation, + }) + updated = true + } + + if updated { + if err := s.K8sClient.Status().Update(ctx, run); err != nil { + return fmt.Errorf("failed to update WorkflowRun status: %w", err) + } + } + + return nil +} + +// stepStatusesEqual compares two slices of StepStatus for equality. +func stepStatusesEqual(a, b []v1alpha2.StepStatus) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Name != b[i].Name || a[i].Phase != b[i].Phase || a[i].Message != b[i].Message || a[i].Retries != b[i].Retries { + return false + } + } + return true +} + +// NeedLeaderElection implements manager.LeaderElectionRunnable so the syncer +// only runs on the leader. +func (s *WorkflowRunStatusSyncer) NeedLeaderElection() bool { + return true +} + diff --git a/go/core/internal/controller/workflowrun_status_syncer_test.go b/go/core/internal/controller/workflowrun_status_syncer_test.go new file mode 100644 index 000000000..158f0e22b --- /dev/null +++ b/go/core/internal/controller/workflowrun_status_syncer_test.go @@ -0,0 +1,429 @@ +package controller + +import ( + "context" + "fmt" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + workflow "github.com/kagent-dev/kagent/go/core/internal/temporal/workflow" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// syncerMockTemporalClient extends mockTemporalClient with step result support. +type syncerMockTemporalClient struct { + describeResults map[string]*WorkflowDescription + describeErr error + queryResults map[string][]workflow.StepResult + queryErr error +} + +func (m *syncerMockTemporalClient) StartWorkflow(_ context.Context, _, _ string, _ *compiler.ExecutionPlan) error { + return nil +} + +func (m *syncerMockTemporalClient) CancelWorkflow(_ context.Context, _ string) error { + return nil +} + +func (m *syncerMockTemporalClient) DescribeWorkflow(_ context.Context, workflowID string) (*WorkflowDescription, error) { + if m.describeErr != nil { + return nil, m.describeErr + } + if desc, ok := m.describeResults[workflowID]; ok { + return desc, nil + } + return &WorkflowDescription{Status: WorkflowExecutionRunning}, nil +} + +func (m *syncerMockTemporalClient) QueryWorkflow(_ context.Context, workflowID, _ string, valuePtr any) error { + if m.queryErr != nil { + return m.queryErr + } + if results, ok := m.queryResults[workflowID]; ok { + if ptr, ok := valuePtr.(*[]workflow.StepResult); ok { + *ptr = results + } + } + return nil +} + +func runningWorkflowRun(name, workflowID string) *v1alpha2.WorkflowRun { + return &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + Status: v1alpha2.WorkflowRunStatus{ + Phase: v1alpha2.WorkflowRunPhaseRunning, + TemporalWorkflowID: workflowID, + Conditions: []metav1.Condition{ + { + Type: v1alpha2.WorkflowRunConditionAccepted, + Status: metav1.ConditionTrue, + Reason: "Accepted", + }, + { + Type: v1alpha2.WorkflowRunConditionRunning, + Status: metav1.ConditionTrue, + Reason: "WorkflowStarted", + }, + }, + }, + } +} + +func TestStatusSyncer_RunningWorkflowStepSync(t *testing.T) { + s := newTestScheme() + run := runningWorkflowRun("test-run", "wf-default-my-template-test-run") + + tc := &syncerMockTemporalClient{ + describeResults: map[string]*WorkflowDescription{ + "wf-default-my-template-test-run": {Status: WorkflowExecutionRunning}, + }, + queryResults: map[string][]workflow.StepResult{ + "wf-default-my-template-test-run": { + {Name: "step-a", Phase: "Succeeded"}, + {Name: "step-b", Phase: "Running"}, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get run: %v", err) + } + + // Should still be Running. + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseRunning { + t.Errorf("Phase = %q, want Running", updated.Status.Phase) + } + + // Steps should be synced. + if len(updated.Status.Steps) != 2 { + t.Fatalf("Steps count = %d, want 2", len(updated.Status.Steps)) + } + if updated.Status.Steps[0].Name != "step-a" || updated.Status.Steps[0].Phase != v1alpha2.StepPhaseSucceeded { + t.Errorf("Step 0 = %+v, want step-a Succeeded", updated.Status.Steps[0]) + } + if updated.Status.Steps[1].Name != "step-b" || updated.Status.Steps[1].Phase != v1alpha2.StepPhaseRunning { + t.Errorf("Step 1 = %+v, want step-b Running", updated.Status.Steps[1]) + } +} + +func TestStatusSyncer_CompletedWorkflow(t *testing.T) { + s := newTestScheme() + run := runningWorkflowRun("test-run", "wf-default-my-template-test-run") + + tc := &syncerMockTemporalClient{ + describeResults: map[string]*WorkflowDescription{ + "wf-default-my-template-test-run": {Status: WorkflowExecutionCompleted}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get run: %v", err) + } + + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseSucceeded { + t.Errorf("Phase = %q, want Succeeded", updated.Status.Phase) + } + if updated.Status.CompletionTime == nil { + t.Error("CompletionTime should be set") + } + + runningCond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionRunning) + if runningCond == nil || runningCond.Status != metav1.ConditionFalse { + t.Error("Running condition should be False") + } + + succeededCond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionSucceeded) + if succeededCond == nil || succeededCond.Status != metav1.ConditionTrue { + t.Error("Succeeded condition should be True") + } +} + +func TestStatusSyncer_FailedWorkflow(t *testing.T) { + s := newTestScheme() + run := runningWorkflowRun("test-run", "wf-default-my-template-test-run") + + tc := &syncerMockTemporalClient{ + describeResults: map[string]*WorkflowDescription{ + "wf-default-my-template-test-run": {Status: WorkflowExecutionFailed, Error: "step-b failed"}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get run: %v", err) + } + + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseFailed { + t.Errorf("Phase = %q, want Failed", updated.Status.Phase) + } + if updated.Status.CompletionTime == nil { + t.Error("CompletionTime should be set") + } + + runningCond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionRunning) + if runningCond == nil || runningCond.Status != metav1.ConditionFalse { + t.Error("Running condition should be False") + } + if runningCond != nil && runningCond.Reason != "WorkflowFailed" { + t.Errorf("Running reason = %q, want WorkflowFailed", runningCond.Reason) + } + + succeededCond := findCondition(updated.Status.Conditions, v1alpha2.WorkflowRunConditionSucceeded) + if succeededCond == nil || succeededCond.Status != metav1.ConditionFalse { + t.Error("Succeeded condition should be False") + } +} + +func TestStatusSyncer_CancelledWorkflow(t *testing.T) { + s := newTestScheme() + run := runningWorkflowRun("test-run", "wf-default-my-template-test-run") + + tc := &syncerMockTemporalClient{ + describeResults: map[string]*WorkflowDescription{ + "wf-default-my-template-test-run": {Status: WorkflowExecutionCancelled}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } + + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get run: %v", err) + } + + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseCancelled { + t.Errorf("Phase = %q, want Cancelled", updated.Status.Phase) + } + if updated.Status.CompletionTime == nil { + t.Error("CompletionTime should be set") + } +} + +func TestStatusSyncer_TemporalDescribeError(t *testing.T) { + s := newTestScheme() + run := runningWorkflowRun("test-run", "wf-default-my-template-test-run") + + tc := &syncerMockTemporalClient{ + describeErr: fmt.Errorf("temporal unavailable"), + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + // Should not return error (logged and continued). + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } + + // Run should not be modified. + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get run: %v", err) + } + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseRunning { + t.Errorf("Phase = %q, want Running (unchanged)", updated.Status.Phase) + } +} + +func TestStatusSyncer_SkipsPendingRuns(t *testing.T) { + s := newTestScheme() + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pending-run", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + Status: v1alpha2.WorkflowRunStatus{ + Phase: v1alpha2.WorkflowRunPhasePending, + }, + } + + tc := &syncerMockTemporalClient{ + describeErr: fmt.Errorf("should not be called"), + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + // Should succeed — pending runs are skipped, so describe is never called. + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } +} + +func TestStatusSyncer_QueryErrorNonFatal(t *testing.T) { + s := newTestScheme() + run := runningWorkflowRun("test-run", "wf-default-my-template-test-run") + + tc := &syncerMockTemporalClient{ + describeResults: map[string]*WorkflowDescription{ + "wf-default-my-template-test-run": {Status: WorkflowExecutionRunning}, + }, + queryErr: fmt.Errorf("query failed"), + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(run). + WithStatusSubresource(run). + Build() + + syncer := &WorkflowRunStatusSyncer{ + K8sClient: fakeClient, + TemporalClient: tc, + } + + // Should not return error — query failure is non-fatal. + if err := syncer.syncAll(context.Background()); err != nil { + t.Fatalf("syncAll() error = %v", err) + } + + // Phase should remain Running. + var updated v1alpha2.WorkflowRun + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "test-run", Namespace: "default"}, &updated); err != nil { + t.Fatalf("failed to get run: %v", err) + } + if updated.Status.Phase != v1alpha2.WorkflowRunPhaseRunning { + t.Errorf("Phase = %q, want Running", updated.Status.Phase) + } +} + +func TestStepStatusesEqual(t *testing.T) { + tests := []struct { + name string + a, b []v1alpha2.StepStatus + want bool + }{ + { + name: "both nil", + a: nil, + b: nil, + want: true, + }, + { + name: "different lengths", + a: []v1alpha2.StepStatus{{Name: "a"}}, + b: nil, + want: false, + }, + { + name: "equal", + a: []v1alpha2.StepStatus{{Name: "a", Phase: v1alpha2.StepPhaseRunning}}, + b: []v1alpha2.StepStatus{{Name: "a", Phase: v1alpha2.StepPhaseRunning}}, + want: true, + }, + { + name: "different phase", + a: []v1alpha2.StepStatus{{Name: "a", Phase: v1alpha2.StepPhaseRunning}}, + b: []v1alpha2.StepStatus{{Name: "a", Phase: v1alpha2.StepPhaseSucceeded}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stepStatusesEqual(tt.a, tt.b) + if got != tt.want { + t.Errorf("stepStatusesEqual() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStatusSyncer_NeedLeaderElection(t *testing.T) { + syncer := &WorkflowRunStatusSyncer{} + if !syncer.NeedLeaderElection() { + t.Error("NeedLeaderElection() should return true") + } +} diff --git a/go/core/internal/controller/workflowtemplate_controller.go b/go/core/internal/controller/workflowtemplate_controller.go new file mode 100644 index 000000000..9f9b166cb --- /dev/null +++ b/go/core/internal/controller/workflowtemplate_controller.go @@ -0,0 +1,130 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "strings" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const ( + // WorkflowTemplateConditionAccepted indicates whether the template passed validation. + WorkflowTemplateConditionAccepted = "Accepted" +) + +// WorkflowTemplateController reconciles WorkflowTemplate objects. +// It validates the DAG structure on create/update and updates status conditions. +type WorkflowTemplateController struct { + client.Client + Scheme *runtime.Scheme + Compiler *compiler.DAGCompiler +} + +// +kubebuilder:rbac:groups=kagent.dev,resources=workflowtemplates,verbs=get;list;watch +// +kubebuilder:rbac:groups=kagent.dev,resources=workflowtemplates/status,verbs=get;update;patch + +func (r *WorkflowTemplateController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var template v1alpha2.WorkflowTemplate + if err := r.Get(ctx, req.NamespacedName, &template); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Skip if already reconciled for this generation. + if template.Status.ObservedGeneration == template.Generation { + return ctrl.Result{}, nil + } + + logger.Info("Validating WorkflowTemplate", "name", template.Name) + + if err := r.Compiler.Validate(&template.Spec); err != nil { + reason := classifyValidationError(err) + meta.SetStatusCondition(&template.Status.Conditions, metav1.Condition{ + Type: WorkflowTemplateConditionAccepted, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + ObservedGeneration: template.Generation, + }) + template.Status.Validated = false + template.Status.StepCount = int32(len(template.Spec.Steps)) + } else { + meta.SetStatusCondition(&template.Status.Conditions, metav1.Condition{ + Type: WorkflowTemplateConditionAccepted, + Status: metav1.ConditionTrue, + Reason: "Valid", + Message: "Template DAG is valid", + ObservedGeneration: template.Generation, + }) + template.Status.Validated = true + template.Status.StepCount = int32(len(template.Spec.Steps)) + } + + template.Status.ObservedGeneration = template.Generation + if err := r.Status().Update(ctx, &template); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update WorkflowTemplate status: %w", err) + } + + return ctrl.Result{}, nil +} + +// classifyValidationError maps compiler error messages to condition reasons. +func classifyValidationError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "cycle detected"): + return "CycleDetected" + case strings.Contains(msg, "duplicate step name"): + return "DuplicateStepName" + case strings.Contains(msg, "nonexistent step"), + strings.Contains(msg, "depends on itself"): + return "InvalidReference" + case strings.Contains(msg, "exceeds maximum"): + return "TooManySteps" + case strings.Contains(msg, "must have"): + return "InvalidStepSpec" + default: + return "ValidationFailed" + } +} + +// SetupWithManager sets up the controller with the Manager. +func (r *WorkflowTemplateController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + WithOptions(controller.Options{ + NeedLeaderElection: ptr.To(true), + }). + For(&v1alpha2.WorkflowTemplate{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Named("workflowtemplate"). + Complete(r) +} diff --git a/go/core/internal/controller/workflowtemplate_controller_test.go b/go/core/internal/controller/workflowtemplate_controller_test.go new file mode 100644 index 000000000..b1dd750a1 --- /dev/null +++ b/go/core/internal/controller/workflowtemplate_controller_test.go @@ -0,0 +1,333 @@ +package controller + +import ( + "context" + "fmt" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newTestScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = v1alpha2.AddToScheme(s) + return s +} + +func TestWorkflowTemplateController_Reconcile(t *testing.T) { + tests := []struct { + name string + template *v1alpha2.WorkflowTemplate + wantValidated bool + wantAccepted metav1.ConditionStatus + wantReason string + wantStepCount int32 + }{ + { + name: "valid linear DAG", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-template", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "step-b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-a"}}, + {Name: "step-c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-b"}}, + }, + }, + }, + wantValidated: true, + wantAccepted: metav1.ConditionTrue, + wantReason: "Valid", + wantStepCount: 3, + }, + { + name: "cycle detected", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cycle-template", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"c"}}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"a"}}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"b"}}, + }, + }, + }, + wantValidated: false, + wantAccepted: metav1.ConditionFalse, + wantReason: "CycleDetected", + wantStepCount: 3, + }, + { + name: "duplicate step name", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dup-template", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + }, + }, + wantValidated: false, + wantAccepted: metav1.ConditionFalse, + wantReason: "DuplicateStepName", + wantStepCount: 2, + }, + { + name: "invalid reference", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-ref-template", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"nonexistent"}}, + }, + }, + }, + wantValidated: false, + wantAccepted: metav1.ConditionFalse, + wantReason: "InvalidReference", + wantStepCount: 1, + }, + { + name: "action step missing action field", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-action", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction}, + }, + }, + }, + wantValidated: false, + wantAccepted: metav1.ConditionFalse, + wantReason: "InvalidStepSpec", + wantStepCount: 1, + }, + { + name: "agent step missing agentRef", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-agentref", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAgent, Prompt: "analyze this"}, + }, + }, + }, + wantValidated: false, + wantAccepted: metav1.ConditionFalse, + wantReason: "InvalidStepSpec", + wantStepCount: 1, + }, + { + name: "parallel DAG with fan-in", + template: &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parallel-template", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "start", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "branch-a", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"start"}}, + {Name: "branch-b", Type: v1alpha2.StepTypeAgent, AgentRef: "my-agent", Prompt: "go", DependsOn: []string{"start"}}, + {Name: "join", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"branch-a", "branch-b"}}, + }, + }, + }, + wantValidated: true, + wantAccepted: metav1.ConditionTrue, + wantReason: "Valid", + wantStepCount: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(tt.template). + WithStatusSubresource(tt.template). + Build() + + r := &WorkflowTemplateController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: tt.template.Name, + Namespace: tt.template.Namespace, + }, + } + + result, err := r.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.Requeue { + t.Errorf("Reconcile() unexpected requeue") + } + + // Fetch the updated template. + var updated v1alpha2.WorkflowTemplate + if err := fakeClient.Get(context.Background(), req.NamespacedName, &updated); err != nil { + t.Fatalf("failed to get updated template: %v", err) + } + + if updated.Status.Validated != tt.wantValidated { + t.Errorf("Validated = %v, want %v", updated.Status.Validated, tt.wantValidated) + } + if updated.Status.StepCount != tt.wantStepCount { + t.Errorf("StepCount = %d, want %d", updated.Status.StepCount, tt.wantStepCount) + } + if updated.Status.ObservedGeneration != tt.template.Generation { + t.Errorf("ObservedGeneration = %d, want %d", updated.Status.ObservedGeneration, tt.template.Generation) + } + + // Check Accepted condition. + cond := findCondition(updated.Status.Conditions, WorkflowTemplateConditionAccepted) + if cond == nil { + t.Fatal("Accepted condition not found") + } + if cond.Status != tt.wantAccepted { + t.Errorf("Accepted status = %v, want %v", cond.Status, tt.wantAccepted) + } + if cond.Reason != tt.wantReason { + t.Errorf("Accepted reason = %q, want %q", cond.Reason, tt.wantReason) + } + }) + } +} + +func TestWorkflowTemplateController_SkipsReconciledGeneration(t *testing.T) { + s := newTestScheme() + template := &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "already-reconciled", + Namespace: "default", + Generation: 1, + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + }, + Status: v1alpha2.WorkflowTemplateStatus{ + ObservedGeneration: 1, + Validated: true, + StepCount: 1, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(template). + WithStatusSubresource(template). + Build() + + r := &WorkflowTemplateController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + } + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "already-reconciled", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.Requeue { + t.Error("should not requeue for already-reconciled generation") + } +} + +func TestWorkflowTemplateController_NotFoundIgnored(t *testing.T) { + s := newTestScheme() + fakeClient := fake.NewClientBuilder().WithScheme(s).Build() + + r := &WorkflowTemplateController{ + Client: fakeClient, + Scheme: s, + Compiler: compiler.NewDAGCompiler(), + } + + result, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "nonexistent", Namespace: "default"}, + }) + if err != nil { + t.Fatalf("Reconcile() error = %v, want nil for not found", err) + } + if result.Requeue { + t.Error("should not requeue for not found") + } +} + +func TestClassifyValidationError(t *testing.T) { + tests := []struct { + errMsg string + want string + }{ + {"cycle detected among steps: a, b, c", "CycleDetected"}, + {"duplicate step name: foo", "DuplicateStepName"}, + {"depends on nonexistent step: Y", "InvalidReference"}, + {"step X depends on itself", "InvalidReference"}, + {"step count exceeds maximum", "TooManySteps"}, + {"action step must have 'action' field", "InvalidStepSpec"}, + {"agent step must have 'agentRef' field", "InvalidStepSpec"}, + {"some other error", "ValidationFailed"}, + } + + for _, tt := range tests { + t.Run(tt.errMsg, func(t *testing.T) { + got := classifyValidationError(fmt.Errorf("%s", tt.errMsg)) + if got != tt.want { + t.Errorf("classifyValidationError(%q) = %q, want %q", tt.errMsg, got, tt.want) + } + }) + } +} + +func findCondition(conditions []metav1.Condition, condType string) *metav1.Condition { + for i := range conditions { + if conditions[i].Type == condType { + return &conditions[i] + } + } + return nil +} diff --git a/go/core/internal/database/client.go b/go/core/internal/database/client.go index 6de488606..443386aec 100644 --- a/go/core/internal/database/client.go +++ b/go/core/internal/database/client.go @@ -575,6 +575,28 @@ func (c *clientImpl) GetCrewAIFlowState(userID, threadID string) (*dbpkg.CrewAIF return &state, nil } +// Plugin methods + +func (c *clientImpl) StorePlugin(plugin *dbpkg.Plugin) (*dbpkg.Plugin, error) { + err := save(c.db, plugin) + if err != nil { + return nil, err + } + return plugin, nil +} + +func (c *clientImpl) DeletePlugin(name string) error { + return delete[dbpkg.Plugin](c.db, Clause{Key: "name", Value: name}) +} + +func (c *clientImpl) GetPluginByPathPrefix(pathPrefix string) (*dbpkg.Plugin, error) { + return get[dbpkg.Plugin](c.db, Clause{Key: "path_prefix", Value: pathPrefix}) +} + +func (c *clientImpl) ListPlugins() ([]dbpkg.Plugin, error) { + return list[dbpkg.Plugin](c.db) +} + // AgentMemory methods func (c *clientImpl) StoreAgentMemory(memory *dbpkg.Memory) error { diff --git a/go/core/internal/database/fake/client.go b/go/core/internal/database/fake/client.go index d40231bf5..d010aa64d 100644 --- a/go/core/internal/database/fake/client.go +++ b/go/core/internal/database/fake/client.go @@ -25,6 +25,7 @@ type InMemoryFakeClient struct { agents map[string]*database.Agent // changed from teams toolServers map[string]*database.ToolServer tools map[string]*database.Tool + plugins map[string]*database.Plugin eventsBySession map[string][]*database.Event // key: sessionId events map[string]*database.Event // key: eventID pushNotifications map[string]*protocol.TaskPushNotificationConfig // key: taskID @@ -45,6 +46,7 @@ func NewClient() database.Client { agents: make(map[string]*database.Agent), toolServers: make(map[string]*database.ToolServer), tools: make(map[string]*database.Tool), + plugins: make(map[string]*database.Plugin), eventsBySession: make(map[string][]*database.Event), events: make(map[string]*database.Event), pushNotifications: make(map[string]*protocol.TaskPushNotificationConfig), @@ -1029,6 +1031,47 @@ func (c *InMemoryFakeClient) DeleteAgentMemory(agentName, userID string) error { return nil } +// Plugin methods + +func (c *InMemoryFakeClient) StorePlugin(plugin *database.Plugin) (*database.Plugin, error) { + c.mu.Lock() + defer c.mu.Unlock() + + c.plugins[plugin.Name] = plugin + return plugin, nil +} + +func (c *InMemoryFakeClient) DeletePlugin(name string) error { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.plugins, name) + return nil +} + +func (c *InMemoryFakeClient) GetPluginByPathPrefix(pathPrefix string) (*database.Plugin, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, plugin := range c.plugins { + if plugin.PathPrefix == pathPrefix { + return plugin, nil + } + } + return nil, gorm.ErrRecordNotFound +} + +func (c *InMemoryFakeClient) ListPlugins() ([]database.Plugin, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + var result []database.Plugin + for _, plugin := range c.plugins { + result = append(result, *plugin) + } + return result, nil +} + // PruneExpiredMemories removes all memories whose ExpiresAt is in the past func (c *InMemoryFakeClient) PruneExpiredMemories() error { c.mu.Lock() diff --git a/go/core/internal/database/manager.go b/go/core/internal/database/manager.go index 7b0c45a11..7fe62bf50 100644 --- a/go/core/internal/database/manager.go +++ b/go/core/internal/database/manager.go @@ -124,6 +124,7 @@ func (m *Manager) Initialize() error { &dbpkg.LangGraphCheckpointWrite{}, &dbpkg.CrewAIAgentMemory{}, &dbpkg.CrewAIFlowState{}, + &dbpkg.Plugin{}, ) if err != nil { @@ -195,6 +196,7 @@ func (m *Manager) Reset(recreateTables bool) error { &dbpkg.CrewAIAgentMemory{}, &dbpkg.CrewAIFlowState{}, &dbpkg.Memory{}, + &dbpkg.Plugin{}, ) if err != nil { diff --git a/go/core/internal/httpserver/errors/errors.go b/go/core/internal/httpserver/errors/errors.go index 54f98f391..e2cfdb1a0 100644 --- a/go/core/internal/httpserver/errors/errors.go +++ b/go/core/internal/httpserver/errors/errors.go @@ -89,3 +89,19 @@ func NewForbiddenError(message string, err error) *APIError { Err: err, } } + +func NewBadGatewayError(message string, err error) *APIError { + return &APIError{ + Code: http.StatusBadGateway, + Message: message, + Err: err, + } +} + +func NewServiceUnavailableError(message string, err error) *APIError { + return &APIError{ + Code: http.StatusServiceUnavailable, + Message: message, + Err: err, + } +} diff --git a/go/core/internal/httpserver/handlers/cronjobs.go b/go/core/internal/httpserver/handlers/cronjobs.go new file mode 100644 index 000000000..be9c455cf --- /dev/null +++ b/go/core/internal/httpserver/handlers/cronjobs.go @@ -0,0 +1,242 @@ +package handlers + +import ( + "net/http" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + "github.com/kagent-dev/kagent/go/core/internal/utils" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// AgentCronJobsHandler handles agentcronjob-related requests +type AgentCronJobsHandler struct { + *Base +} + +// NewAgentCronJobsHandler creates a new AgentCronJobsHandler +func NewAgentCronJobsHandler(base *Base) *AgentCronJobsHandler { + return &AgentCronJobsHandler{Base: base} +} + +// HandleListCronJobs handles GET /api/cronjobs requests +func (h *AgentCronJobsHandler) HandleListCronJobs(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("cronjobs-handler").WithValues("operation", "list") + + if err := Check(h.Authorizer, r, auth.Resource{Type: "AgentCronJob"}); err != nil { + w.RespondWithError(err) + return + } + + cronJobList := &v1alpha2.AgentCronJobList{} + if err := h.KubeClient.List(r.Context(), cronJobList); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to list AgentCronJobs", err)) + return + } + + log.Info("Successfully listed AgentCronJobs", "count", len(cronJobList.Items)) + data := api.NewResponse(cronJobList.Items, "Successfully listed AgentCronJobs", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleGetCronJob handles GET /api/cronjobs/{namespace}/{name} requests +func (h *AgentCronJobsHandler) HandleGetCronJob(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("cronjobs-handler").WithValues("operation", "get") + + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return + } + log = log.WithValues("name", name) + + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return + } + log = log.WithValues("namespace", namespace) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "AgentCronJob", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + cronJob := &v1alpha2.AgentCronJob{} + if err := h.KubeClient.Get(r.Context(), client.ObjectKey{ + Namespace: namespace, + Name: name, + }, cronJob); err != nil { + if apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewNotFoundError("AgentCronJob not found", err)) + } else { + w.RespondWithError(errors.NewInternalServerError("Failed to get AgentCronJob", err)) + } + return + } + + log.Info("Successfully retrieved AgentCronJob") + data := api.NewResponse(cronJob, "Successfully retrieved AgentCronJob", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleCreateCronJob handles POST /api/cronjobs requests +func (h *AgentCronJobsHandler) HandleCreateCronJob(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("cronjobs-handler").WithValues("operation", "create") + + var cronJobReq v1alpha2.AgentCronJob + if err := DecodeJSONBody(r, &cronJobReq); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + + if cronJobReq.Namespace == "" { + cronJobReq.Namespace = utils.GetResourceNamespace() + log.V(4).Info("Namespace not provided, using default", "namespace", cronJobReq.Namespace) + } + + if cronJobReq.Name == "" { + w.RespondWithError(errors.NewBadRequestError("Name is required", nil)) + return + } + + if cronJobReq.Spec.Schedule == "" || cronJobReq.Spec.Prompt == "" || cronJobReq.Spec.AgentRef == "" { + w.RespondWithError(errors.NewBadRequestError("Schedule, Prompt, and AgentRef are required", nil)) + return + } + + log = log.WithValues("namespace", cronJobReq.Namespace, "name", cronJobReq.Name) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "AgentCronJob", Name: types.NamespacedName{Namespace: cronJobReq.Namespace, Name: cronJobReq.Name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + // Check if already exists + existing := &v1alpha2.AgentCronJob{} + err := h.KubeClient.Get(r.Context(), client.ObjectKey{ + Namespace: cronJobReq.Namespace, + Name: cronJobReq.Name, + }, existing) + if err == nil { + w.RespondWithError(errors.NewConflictError("AgentCronJob already exists", nil)) + return + } else if !apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewInternalServerError("Failed to check if AgentCronJob exists", err)) + return + } + + if err := h.KubeClient.Create(r.Context(), &cronJobReq); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to create AgentCronJob", err)) + return + } + + log.Info("Successfully created AgentCronJob") + data := api.NewResponse(&cronJobReq, "Successfully created AgentCronJob", false) + RespondWithJSON(w, http.StatusCreated, data) +} + +// HandleUpdateCronJob handles PUT /api/cronjobs/{namespace}/{name} requests +func (h *AgentCronJobsHandler) HandleUpdateCronJob(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("cronjobs-handler").WithValues("operation", "update") + + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return + } + log = log.WithValues("name", name) + + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return + } + log = log.WithValues("namespace", namespace) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "AgentCronJob", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + var cronJobReq v1alpha2.AgentCronJob + if err := DecodeJSONBody(r, &cronJobReq); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + + existing := &v1alpha2.AgentCronJob{} + if err := h.KubeClient.Get(r.Context(), client.ObjectKey{ + Namespace: namespace, + Name: name, + }, existing); err != nil { + if apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewNotFoundError("AgentCronJob not found", err)) + } else { + w.RespondWithError(errors.NewInternalServerError("Failed to get AgentCronJob", err)) + } + return + } + + existing.Spec = cronJobReq.Spec + + if err := h.KubeClient.Update(r.Context(), existing); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to update AgentCronJob", err)) + return + } + + log.Info("Successfully updated AgentCronJob") + data := api.NewResponse(existing, "Successfully updated AgentCronJob", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleDeleteCronJob handles DELETE /api/cronjobs/{namespace}/{name} requests +func (h *AgentCronJobsHandler) HandleDeleteCronJob(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("cronjobs-handler").WithValues("operation", "delete") + + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return + } + log = log.WithValues("name", name) + + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return + } + log = log.WithValues("namespace", namespace) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "AgentCronJob", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + cronJob := &v1alpha2.AgentCronJob{} + if err := h.KubeClient.Get(r.Context(), client.ObjectKey{ + Namespace: namespace, + Name: name, + }, cronJob); err != nil { + if apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewNotFoundError("AgentCronJob not found", err)) + } else { + w.RespondWithError(errors.NewInternalServerError("Failed to get AgentCronJob", err)) + } + return + } + + if err := h.KubeClient.Delete(r.Context(), cronJob); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to delete AgentCronJob", err)) + return + } + + log.Info("Successfully deleted AgentCronJob") + data := api.NewResponse(struct{}{}, "Successfully deleted AgentCronJob", false) + RespondWithJSON(w, http.StatusOK, data) +} diff --git a/go/core/internal/httpserver/handlers/dashboard.go b/go/core/internal/httpserver/handlers/dashboard.go new file mode 100644 index 000000000..c79c7ff79 --- /dev/null +++ b/go/core/internal/httpserver/handlers/dashboard.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "net/http" + "time" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// DashboardHandler handles dashboard-related requests +type DashboardHandler struct { + *Base +} + +// NewDashboardHandler creates a new DashboardHandler +func NewDashboardHandler(base *Base) *DashboardHandler { + return &DashboardHandler{Base: base} +} + +// HandleDashboardStats handles GET /api/dashboard/stats requests +func (h *DashboardHandler) HandleDashboardStats(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("dashboard-handler").WithValues("operation", "stats") + + userID, err := GetUserID(r) + if err != nil { + log.V(1).Info("Failed to get user ID, using empty string for counts", "error", err) + userID = "" + } + + // Count agents + agentCount := 0 + agents, err := h.DatabaseService.ListAgents() + if err != nil { + log.Error(err, "Failed to list agents for dashboard count") + } else { + agentCount = len(agents) + } + + // Count tools + toolCount := 0 + tools, err := h.DatabaseService.ListTools() + if err != nil { + log.Error(err, "Failed to list tools for dashboard count") + } else { + toolCount = len(tools) + } + + // Count MCP servers (tool servers) + mcpServerCount := 0 + toolServers, err := h.DatabaseService.ListToolServers() + if err != nil { + log.Error(err, "Failed to list tool servers for dashboard count") + } else { + mcpServerCount = len(toolServers) + } + + counts := api.DashboardCounts{ + Agents: agentCount, + Tools: toolCount, + MCPServers: mcpServerCount, + // K8s-only resources — will be wired to K8s list calls later + Workflows: 0, + CronJobs: 0, + Models: 0, + GitRepos: 0, + } + + // Recent runs (sessions) + var recentRuns []api.RecentRun + if userID != "" { + sessions, err := h.DatabaseService.ListSessions(userID) + if err != nil { + log.Error(err, "Failed to list sessions for dashboard recent runs") + } else { + limit := 10 + if len(sessions) < limit { + limit = len(sessions) + } + recentRuns = make([]api.RecentRun, 0, limit) + for i := 0; i < limit; i++ { + s := sessions[i] + sessionName := "" + if s.Name != nil { + sessionName = *s.Name + } + agentName := "" + if s.AgentID != nil { + agentName = *s.AgentID + } + recentRuns = append(recentRuns, api.RecentRun{ + SessionID: s.ID, + SessionName: sessionName, + AgentName: agentName, + CreatedAt: s.CreatedAt.Format(time.RFC3339), + UpdatedAt: s.UpdatedAt.Format(time.RFC3339), + }) + } + } + } + if recentRuns == nil { + recentRuns = []api.RecentRun{} + } + + // Recent events — fetching all events requires a session ID in the current API + recentEvents := []api.RecentEvent{} + + response := api.DashboardStatsResponse{ + Counts: counts, + RecentRuns: recentRuns, + RecentEvents: recentEvents, + } + + log.Info("Successfully retrieved dashboard stats") + RespondWithJSON(w, http.StatusOK, response) +} diff --git a/go/core/internal/httpserver/handlers/gitrepos.go b/go/core/internal/httpserver/handlers/gitrepos.go new file mode 100644 index 000000000..73fdd9c7d --- /dev/null +++ b/go/core/internal/httpserver/handlers/gitrepos.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// GitReposHandler proxies git repository management requests to the gitrepo-mcp service. +type GitReposHandler struct { + *Base + GitRepoMCPURL string + httpClient *http.Client +} + +// NewGitReposHandler creates a new GitReposHandler. +func NewGitReposHandler(base *Base, gitRepoMCPURL string) *GitReposHandler { + return &GitReposHandler{ + Base: base, + GitRepoMCPURL: gitRepoMCPURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// proxy forwards a request to the gitrepo-mcp service and streams the response back. +func (h *GitReposHandler) proxy(w ErrorResponseWriter, r *http.Request, method, downstreamPath string) { + log := ctrllog.FromContext(r.Context()).WithName("gitrepos-handler") + + if h.GitRepoMCPURL == "" { + w.RespondWithError(errors.NewServiceUnavailableError("gitrepo-mcp service not configured", nil)) + return + } + + targetURL := strings.TrimRight(h.GitRepoMCPURL, "/") + downstreamPath + + req, err := http.NewRequestWithContext(r.Context(), method, targetURL, r.Body) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("failed to create proxy request", err)) + return + } + req.Header.Set("Content-Type", "application/json") + + log.V(1).Info("Proxying request", "method", method, "target", targetURL) + + resp, err := h.httpClient.Do(req) + if err != nil { + log.Error(err, "Failed to reach gitrepo-mcp service", "url", targetURL) + w.RespondWithError(errors.NewBadGatewayError("gitrepo-mcp service unavailable", err)) + return + } + defer resp.Body.Close() + + // Copy headers from downstream response + for key, values := range resp.Header { + for _, v := range values { + w.Header().Add(key, v) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) //nolint:errcheck +} + +// HandleListRepos handles GET /api/gitrepos +func (h *GitReposHandler) HandleListRepos(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + h.proxy(w, r, http.MethodGet, "/api/repos") +} + +// HandleAddRepo handles POST /api/gitrepos +func (h *GitReposHandler) HandleAddRepo(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + h.proxy(w, r, http.MethodPost, "/api/repos") +} + +// HandleGetRepo handles GET /api/gitrepos/{name} +func (h *GitReposHandler) HandleGetRepo(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("name is required", err)) + return + } + h.proxy(w, r, http.MethodGet, fmt.Sprintf("/api/repos/%s", name)) +} + +// HandleDeleteRepo handles DELETE /api/gitrepos/{name} +func (h *GitReposHandler) HandleDeleteRepo(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("name is required", err)) + return + } + h.proxy(w, r, http.MethodDelete, fmt.Sprintf("/api/repos/%s", name)) +} + +// HandleSyncRepo handles POST /api/gitrepos/{name}/sync +func (h *GitReposHandler) HandleSyncRepo(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("name is required", err)) + return + } + h.proxy(w, r, http.MethodPost, fmt.Sprintf("/api/repos/%s/sync", name)) +} + +// HandleIndexRepo handles POST /api/gitrepos/{name}/index +func (h *GitReposHandler) HandleIndexRepo(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("name is required", err)) + return + } + h.proxy(w, r, http.MethodPost, fmt.Sprintf("/api/repos/%s/index", name)) +} + +// HandleSearchRepo handles POST /api/gitrepos/{name}/search +func (h *GitReposHandler) HandleSearchRepo(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("name is required", err)) + return + } + h.proxy(w, r, http.MethodPost, fmt.Sprintf("/api/repos/%s/search", name)) +} + +// HandleSearchAll handles POST /api/gitrepos/search +func (h *GitReposHandler) HandleSearchAll(w ErrorResponseWriter, r *http.Request) { + if err := Check(h.Authorizer, r, auth.Resource{Type: "GitRepo"}); err != nil { + w.RespondWithError(err) + return + } + h.proxy(w, r, http.MethodPost, "/api/search") +} diff --git a/go/core/internal/httpserver/handlers/handlers.go b/go/core/internal/httpserver/handlers/handlers.go index 12ad54e94..c18f7b417 100644 --- a/go/core/internal/httpserver/handlers/handlers.go +++ b/go/core/internal/httpserver/handlers/handlers.go @@ -26,6 +26,12 @@ type Handlers struct { Tasks *TasksHandler Checkpoints *CheckpointsHandler CrewAI *CrewAIHandler + AgentCronJobs *AgentCronJobsHandler + GitRepos *GitReposHandler + Plugins *PluginsHandler + PluginProxy *PluginProxyHandler + Dashboard *DashboardHandler + Workflows *WorkflowsHandler } // Base holds common dependencies for all handlers @@ -38,7 +44,7 @@ type Base struct { } // NewHandlers creates a new Handlers instance with all handler components. -func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string, rcnclr reconciler.KagentReconciler) *Handlers { +func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string, rcnclr reconciler.KagentReconciler, gitRepoMCPURL string) *Handlers { base := &Base{ KubeClient: kubeClient, DefaultModelConfig: defaultModelConfig, @@ -63,5 +69,11 @@ func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedNa Tasks: NewTasksHandler(base), Checkpoints: NewCheckpointsHandler(base), CrewAI: NewCrewAIHandler(base), + AgentCronJobs: NewAgentCronJobsHandler(base), + GitRepos: NewGitReposHandler(base, gitRepoMCPURL), + Plugins: NewPluginsHandler(base), + PluginProxy: NewPluginProxyHandler(base), + Dashboard: NewDashboardHandler(base), + Workflows: NewWorkflowsHandler(base), } } diff --git a/go/core/internal/httpserver/handlers/pluginproxy.go b/go/core/internal/httpserver/handlers/pluginproxy.go new file mode 100644 index 000000000..40357e2d8 --- /dev/null +++ b/go/core/internal/httpserver/handlers/pluginproxy.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/gorilla/mux" + "github.com/kagent-dev/kagent/go/api/database" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// PluginProxyHandler handles /_p/{name}/ reverse proxy requests +type PluginProxyHandler struct { + *Base + proxies sync.Map // pathPrefix -> *httputil.ReverseProxy +} + +// NewPluginProxyHandler creates a new PluginProxyHandler +func NewPluginProxyHandler(base *Base) *PluginProxyHandler { + return &PluginProxyHandler{Base: base} +} + +// HandleProxy handles all requests to /_p/{name}/{path...} +func (h *PluginProxyHandler) HandleProxy(w http.ResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("plugin-proxy") + + pathPrefix := mux.Vars(r)["name"] + if pathPrefix == "" { + http.Error(w, "plugin name required", http.StatusBadRequest) + return + } + + plugin, err := h.DatabaseService.GetPluginByPathPrefix(pathPrefix) + if err != nil { + log.V(1).Info("Plugin not found", "pathPrefix", pathPrefix) + http.Error(w, "plugin not found", http.StatusNotFound) + return + } + + proxy := h.getOrCreateProxy(plugin) + + // Strip the /_p/{name} prefix before forwarding + originalPath := r.URL.Path + prefix := "/_p/" + pathPrefix + r.URL.Path = strings.TrimPrefix(originalPath, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + + // Redirect plugin root to default path if configured + if r.URL.Path == "/" && plugin.DefaultPath != "" { + http.Redirect(w, r, prefix+plugin.DefaultPath, http.StatusTemporaryRedirect) + return + } + + proxy.ServeHTTP(w, r) +} + +func (h *PluginProxyHandler) getOrCreateProxy(plugin *database.Plugin) *httputil.ReverseProxy { + if cached, ok := h.proxies.Load(plugin.PathPrefix); ok { + return cached.(*httputil.ReverseProxy) + } + + target, _ := url.Parse(plugin.UpstreamURL) + proxyPrefix := "/_p/" + plugin.PathPrefix + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Header.Set("X-Forwarded-Host", req.Host) + req.Header.Set("X-Plugin-Name", plugin.PathPrefix) + // Remove Accept-Encoding so we get uncompressed responses for rewriting + req.Header.Del("Accept-Encoding") + }, + ModifyResponse: makePathRewriter(proxyPrefix, plugin.InjectCSS), + // Flush immediately for SSE support + FlushInterval: -1, + } + + h.proxies.Store(plugin.PathPrefix, proxy) + return proxy +} + +// cspMetaRe matches tags. +// We strip these because rewriting inline scripts invalidates their CSP hashes. +var cspMetaRe = regexp.MustCompile(`(?i)]+http-equiv=["']content-security-policy["'][^>]*>`) + +// makePathRewriter returns a ModifyResponse function that rewrites absolute +// paths in HTML responses so that SPA assets load through the plugin proxy. +// For example, href="/_app/foo.js" becomes href="/_p/temporal/_app/foo.js". +// If injectCSS is non-empty, a " + content = strings.Replace(content, "", styleTag+"", 1) + } + + rewritten := []byte(content) + resp.Body = io.NopCloser(bytes.NewReader(rewritten)) + resp.ContentLength = int64(len(rewritten)) + resp.Header.Set("Content-Length", strconv.Itoa(len(rewritten))) + + return nil + } +} + +// InvalidateCache removes a cached proxy (called when plugin is updated/deleted) +func (h *PluginProxyHandler) InvalidateCache(pathPrefix string) { + h.proxies.Delete(pathPrefix) +} diff --git a/go/core/internal/httpserver/handlers/pluginproxy_test.go b/go/core/internal/httpserver/handlers/pluginproxy_test.go new file mode 100644 index 000000000..90b172c93 --- /dev/null +++ b/go/core/internal/httpserver/handlers/pluginproxy_test.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/kagent-dev/kagent/go/api/database" + fake "github.com/kagent-dev/kagent/go/core/internal/database/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newPluginProxyHandlerWithFakeDB(t *testing.T) (*PluginProxyHandler, *fake.InMemoryFakeClient) { + t.Helper() + dbClient := fake.NewClient() + fakeClient, ok := dbClient.(*fake.InMemoryFakeClient) + require.True(t, ok) + base := &Base{DatabaseService: dbClient} + return NewPluginProxyHandler(base), fakeClient +} + +func TestPluginProxyHandler_NotFound(t *testing.T) { + h, _ := newPluginProxyHandlerWithFakeDB(t) + + req := httptest.NewRequest(http.MethodGet, "/_p/kanban/api/board", nil) + req = mux.SetURLVars(req, map[string]string{"name": "kanban"}) + w := httptest.NewRecorder() + + h.HandleProxy(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "plugin not found") +} + +func TestPluginProxyHandler_StripsPrefixAndForwardsHeaders(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "path": r.URL.Path, + "forwarded_host": r.Header.Get("X-Forwarded-Host"), + "plugin_name": r.Header.Get("X-Plugin-Name"), + "request_host_hdr": r.Host, + }) + })) + defer upstream.Close() + + h, fakeClient := newPluginProxyHandlerWithFakeDB(t) + _, err := fakeClient.StorePlugin(&database.Plugin{ + Name: "kagent/kanban-mcp", + PathPrefix: "kanban", + DisplayName: "Kanban Board", + Icon: "kanban", + Section: "AGENTS", + UpstreamURL: upstream.URL, + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/_p/kanban/api/board", nil) + req.Host = "kagent.dev" + req = mux.SetURLVars(req, map[string]string{"name": "kanban"}) + w := httptest.NewRecorder() + + h.HandleProxy(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var got map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + assert.Equal(t, "/api/board", got["path"]) + assert.Equal(t, "kagent.dev", got["forwarded_host"]) + assert.Equal(t, "kanban", got["plugin_name"]) +} + +func TestPluginProxyHandler_UsesProxyCache(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + h, fakeClient := newPluginProxyHandlerWithFakeDB(t) + _, err := fakeClient.StorePlugin(&database.Plugin{ + Name: "kagent/kanban-mcp", + PathPrefix: "kanban", + DisplayName: "Kanban Board", + Icon: "kanban", + Section: "AGENTS", + UpstreamURL: upstream.URL, + }) + require.NoError(t, err) + + // First request creates cache entry + req1 := httptest.NewRequest(http.MethodGet, "/_p/kanban/", nil) + req1 = mux.SetURLVars(req1, map[string]string{"name": "kanban"}) + w1 := httptest.NewRecorder() + h.HandleProxy(w1, req1) + require.Equal(t, http.StatusOK, w1.Code) + + cached1, ok := h.proxies.Load("kanban") + require.True(t, ok, "expected proxy cache entry after first request") + + // Second request should reuse same cache entry + req2 := httptest.NewRequest(http.MethodGet, "/_p/kanban/api/tasks", nil) + req2 = mux.SetURLVars(req2, map[string]string{"name": "kanban"}) + w2 := httptest.NewRecorder() + h.HandleProxy(w2, req2) + require.Equal(t, http.StatusOK, w2.Code) + + cached2, ok := h.proxies.Load("kanban") + require.True(t, ok, "expected proxy cache entry after second request") + assert.Same(t, cached1, cached2, "expected cached reverse proxy instance to be reused") +} + +func TestPluginProxyHandler_ProxyRootPath(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer upstream.Close() + + h, fakeClient := newPluginProxyHandlerWithFakeDB(t) + _, err := fakeClient.StorePlugin(&database.Plugin{ + Name: "kagent/kanban-mcp", + PathPrefix: "kanban", + DisplayName: "Kanban Board", + Icon: "kanban", + Section: "AGENTS", + UpstreamURL: upstream.URL, + }) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/_p/kanban", nil) + req = mux.SetURLVars(req, map[string]string{"name": "kanban"}) + w := httptest.NewRecorder() + + h.HandleProxy(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) +} diff --git a/go/core/internal/httpserver/handlers/plugins.go b/go/core/internal/httpserver/handlers/plugins.go new file mode 100644 index 000000000..aad129a1d --- /dev/null +++ b/go/core/internal/httpserver/handlers/plugins.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "net/http" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// PluginsHandler handles plugin-related requests +type PluginsHandler struct { + *Base +} + +// NewPluginsHandler creates a new PluginsHandler +func NewPluginsHandler(base *Base) *PluginsHandler { + return &PluginsHandler{Base: base} +} + +// PluginResponse represents a plugin in the API response +type PluginResponse struct { + Name string `json:"name"` + PathPrefix string `json:"pathPrefix"` + DisplayName string `json:"displayName"` + Icon string `json:"icon"` + Section string `json:"section"` +} + +// HandleListPlugins handles GET /api/plugins - returns all plugins with UI metadata +func (h *PluginsHandler) HandleListPlugins(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("plugins-handler").WithValues("operation", "list") + log.Info("Received request to list plugins") + + plugins, err := h.DatabaseService.ListPlugins() + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to list plugins", err)) + return + } + + resp := make([]PluginResponse, len(plugins)) + for i, p := range plugins { + resp[i] = PluginResponse{ + Name: p.Name, + PathPrefix: p.PathPrefix, + DisplayName: p.DisplayName, + Icon: p.Icon, + Section: p.Section, + } + } + + data := api.NewResponse(resp, "Successfully listed plugins", false) + RespondWithJSON(w, http.StatusOK, data) +} diff --git a/go/core/internal/httpserver/handlers/plugins_test.go b/go/core/internal/httpserver/handlers/plugins_test.go new file mode 100644 index 000000000..5d9847927 --- /dev/null +++ b/go/core/internal/httpserver/handlers/plugins_test.go @@ -0,0 +1,66 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagent-dev/kagent/go/api/database" + api "github.com/kagent-dev/kagent/go/api/httpapi" + fake "github.com/kagent-dev/kagent/go/core/internal/database/fake" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleListPlugins_Empty(t *testing.T) { + dbClient := fake.NewClient() + base := &handlers.Base{DatabaseService: dbClient} + h := handlers.NewPluginsHandler(base) + + req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil) + w := newMockErrorResponseWriter() + + h.HandleListPlugins(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp api.StandardResponse[[]handlers.PluginResponse] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Empty(t, resp.Data) +} + +func TestHandleListPlugins_WithPlugins(t *testing.T) { + dbClient := fake.NewClient() + fakeClient := dbClient.(*fake.InMemoryFakeClient) + + fakeClient.StorePlugin(&database.Plugin{ + Name: "kagent/kanban-mcp", + PathPrefix: "kanban", + DisplayName: "Kanban Board", + Icon: "kanban", + Section: "AGENTS", + UpstreamURL: "http://kanban-mcp:8080", + }) + + base := &handlers.Base{DatabaseService: dbClient} + h := handlers.NewPluginsHandler(base) + + req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil) + w := newMockErrorResponseWriter() + + h.HandleListPlugins(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp api.StandardResponse[[]handlers.PluginResponse] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + assert.Equal(t, "kanban", resp.Data[0].PathPrefix) + assert.Equal(t, "Kanban Board", resp.Data[0].DisplayName) + assert.Equal(t, "kanban", resp.Data[0].Icon) + assert.Equal(t, "AGENTS", resp.Data[0].Section) +} diff --git a/go/core/internal/httpserver/handlers/workflows.go b/go/core/internal/httpserver/handlers/workflows.go new file mode 100644 index 000000000..e90d646e1 --- /dev/null +++ b/go/core/internal/httpserver/handlers/workflows.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "net/http" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + "github.com/kagent-dev/kagent/go/core/internal/utils" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +// WorkflowsHandler handles workflow template and run requests. +type WorkflowsHandler struct { + *Base +} + +// NewWorkflowsHandler creates a new WorkflowsHandler. +func NewWorkflowsHandler(base *Base) *WorkflowsHandler { + return &WorkflowsHandler{Base: base} +} + +// HandleListWorkflowTemplates handles GET /api/workflow-templates requests. +func (h *WorkflowsHandler) HandleListWorkflowTemplates(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("workflows-handler").WithValues("operation", "list-templates") + + if err := Check(h.Authorizer, r, auth.Resource{Type: "WorkflowTemplate"}); err != nil { + w.RespondWithError(err) + return + } + + templateList := &v1alpha2.WorkflowTemplateList{} + if err := h.KubeClient.List(r.Context(), templateList); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to list WorkflowTemplates", err)) + return + } + + log.Info("Successfully listed WorkflowTemplates", "count", len(templateList.Items)) + data := api.NewResponse(templateList.Items, "Successfully listed WorkflowTemplates", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleGetWorkflowTemplate handles GET /api/workflow-templates/{namespace}/{name} requests. +func (h *WorkflowsHandler) HandleGetWorkflowTemplate(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("workflows-handler").WithValues("operation", "get-template") + + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return + } + + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return + } + log = log.WithValues("namespace", namespace, "name", name) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "WorkflowTemplate", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + template := &v1alpha2.WorkflowTemplate{} + if err := h.KubeClient.Get(r.Context(), client.ObjectKey{Namespace: namespace, Name: name}, template); err != nil { + if apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewNotFoundError("WorkflowTemplate not found", err)) + } else { + w.RespondWithError(errors.NewInternalServerError("Failed to get WorkflowTemplate", err)) + } + return + } + + log.Info("Successfully retrieved WorkflowTemplate") + data := api.NewResponse(template, "Successfully retrieved WorkflowTemplate", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleListWorkflowRuns handles GET /api/workflow-runs requests. +func (h *WorkflowsHandler) HandleListWorkflowRuns(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("workflows-handler").WithValues("operation", "list-runs") + + if err := Check(h.Authorizer, r, auth.Resource{Type: "WorkflowRun"}); err != nil { + w.RespondWithError(err) + return + } + + runList := &v1alpha2.WorkflowRunList{} + listOpts := []client.ListOption{} + + // Optional filters via query params + if templateRef := r.URL.Query().Get("templateRef"); templateRef != "" { + listOpts = append(listOpts, client.MatchingLabels{"kagent.dev/workflow-template": templateRef}) + } + + if err := h.KubeClient.List(r.Context(), runList, listOpts...); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to list WorkflowRuns", err)) + return + } + + // Optional status filter (post-filter since phase is in status) + if statusFilter := r.URL.Query().Get("status"); statusFilter != "" { + filtered := make([]v1alpha2.WorkflowRun, 0, len(runList.Items)) + for _, run := range runList.Items { + if run.Status.Phase == statusFilter { + filtered = append(filtered, run) + } + } + runList.Items = filtered + } + + log.Info("Successfully listed WorkflowRuns", "count", len(runList.Items)) + data := api.NewResponse(runList.Items, "Successfully listed WorkflowRuns", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleGetWorkflowRun handles GET /api/workflow-runs/{namespace}/{name} requests. +func (h *WorkflowsHandler) HandleGetWorkflowRun(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("workflows-handler").WithValues("operation", "get-run") + + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return + } + + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return + } + log = log.WithValues("namespace", namespace, "name", name) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "WorkflowRun", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + run := &v1alpha2.WorkflowRun{} + if err := h.KubeClient.Get(r.Context(), client.ObjectKey{Namespace: namespace, Name: name}, run); err != nil { + if apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewNotFoundError("WorkflowRun not found", err)) + } else { + w.RespondWithError(errors.NewInternalServerError("Failed to get WorkflowRun", err)) + } + return + } + + log.Info("Successfully retrieved WorkflowRun") + data := api.NewResponse(run, "Successfully retrieved WorkflowRun", false) + RespondWithJSON(w, http.StatusOK, data) +} + +// HandleCreateWorkflowRun handles POST /api/workflow-runs requests. +func (h *WorkflowsHandler) HandleCreateWorkflowRun(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("workflows-handler").WithValues("operation", "create-run") + + var req api.CreateWorkflowRunRequest + if err := DecodeJSONBody(r, &req); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + + if req.Name == "" { + w.RespondWithError(errors.NewBadRequestError("Name is required", nil)) + return + } + + if req.WorkflowTemplateRef == "" { + w.RespondWithError(errors.NewBadRequestError("workflowTemplateRef is required", nil)) + return + } + + if req.Namespace == "" { + req.Namespace = utils.GetResourceNamespace() + } + + log = log.WithValues("namespace", req.Namespace, "name", req.Name, "templateRef", req.WorkflowTemplateRef) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "WorkflowRun", Name: types.NamespacedName{Namespace: req.Namespace, Name: req.Name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + // Check if already exists + existing := &v1alpha2.WorkflowRun{} + err := h.KubeClient.Get(r.Context(), client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, existing) + if err == nil { + w.RespondWithError(errors.NewConflictError("WorkflowRun already exists", nil)) + return + } else if !apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewInternalServerError("Failed to check if WorkflowRun exists", err)) + return + } + + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Namespace: req.Namespace, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: req.WorkflowTemplateRef, + Params: req.Params, + TTLSecondsAfterFinished: req.TTLSecondsAfterFinished, + }, + } + + if err := h.KubeClient.Create(r.Context(), run); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to create WorkflowRun", err)) + return + } + + log.Info("Successfully created WorkflowRun") + data := api.NewResponse(run, "Successfully created WorkflowRun", false) + RespondWithJSON(w, http.StatusCreated, data) +} + +// HandleDeleteWorkflowRun handles DELETE /api/workflow-runs/{namespace}/{name} requests. +func (h *WorkflowsHandler) HandleDeleteWorkflowRun(w ErrorResponseWriter, r *http.Request) { + log := ctrllog.FromContext(r.Context()).WithName("workflows-handler").WithValues("operation", "delete-run") + + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return + } + + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return + } + log = log.WithValues("namespace", namespace, "name", name) + + if err := Check(h.Authorizer, r, auth.Resource{Type: "WorkflowRun", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return + } + + run := &v1alpha2.WorkflowRun{} + if err := h.KubeClient.Get(r.Context(), client.ObjectKey{Namespace: namespace, Name: name}, run); err != nil { + if apierrors.IsNotFound(err) { + w.RespondWithError(errors.NewNotFoundError("WorkflowRun not found", err)) + } else { + w.RespondWithError(errors.NewInternalServerError("Failed to get WorkflowRun", err)) + } + return + } + + if err := h.KubeClient.Delete(r.Context(), run); err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to delete WorkflowRun", err)) + return + } + + log.Info("Successfully deleted WorkflowRun") + data := api.NewResponse(struct{}{}, "Successfully deleted WorkflowRun", false) + RespondWithJSON(w, http.StatusOK, data) +} diff --git a/go/core/internal/httpserver/handlers/workflows_test.go b/go/core/internal/httpserver/handlers/workflows_test.go new file mode 100644 index 000000000..03d232260 --- /dev/null +++ b/go/core/internal/httpserver/handlers/workflows_test.go @@ -0,0 +1,318 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl_client "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/handlers" + kagentauth "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +func setupWorkflowsHandler(objs ...ctrl_client.Object) (*handlers.WorkflowsHandler, ctrl_client.Client, *mockErrorResponseWriter) { + scheme := runtime.NewScheme() + _ = v1alpha2.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&v1alpha2.WorkflowTemplate{}, &v1alpha2.WorkflowRun{}). + Build() + base := &handlers.Base{ + KubeClient: kubeClient, + DefaultModelConfig: types.NamespacedName{Namespace: "default", Name: "default"}, + Authorizer: &auth.NoopAuthorizer{}, + } + handler := handlers.NewWorkflowsHandler(base) + recorder := newMockErrorResponseWriter() + return handler, kubeClient, recorder +} + +func workflowSetUser(req *http.Request, userID string) *http.Request { + ctx := kagentauth.AuthSessionTo(req.Context(), &authimpl.SimpleSession{ + P: kagentauth.Principal{ + User: kagentauth.User{ + ID: userID, + }, + }, + }) + return req.WithContext(ctx) +} + +func withVars(r *http.Request, vars map[string]string) *http.Request { + return mux.SetURLVars(r, vars) +} + +func TestWorkflowsHandler_ListTemplates(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + req := httptest.NewRequest("GET", "/api/workflow-templates", nil) + req = workflowSetUser(req, "test-user") + handler.HandleListWorkflowTemplates(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var resp api.StandardResponse[[]v1alpha2.WorkflowTemplate] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 0) + }) + + t.Run("returns templates", func(t *testing.T) { + tmpl := &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "build-test", Namespace: "default"}, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + }, + } + handler, _, recorder := setupWorkflowsHandler(tmpl) + + req := httptest.NewRequest("GET", "/api/workflow-templates", nil) + req = workflowSetUser(req, "test-user") + handler.HandleListWorkflowTemplates(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var resp api.StandardResponse[[]v1alpha2.WorkflowTemplate] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + require.Equal(t, "build-test", resp.Data[0].Name) + }) +} + +func TestWorkflowsHandler_GetTemplate(t *testing.T) { + t.Run("found", func(t *testing.T) { + tmpl := &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "my-tmpl", Namespace: "default"}, + Spec: v1alpha2.WorkflowTemplateSpec{ + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + }, + } + handler, _, recorder := setupWorkflowsHandler(tmpl) + + req := httptest.NewRequest("GET", "/api/workflow-templates/default/my-tmpl", nil) + req = workflowSetUser(req, "test-user") + req = withVars(req, map[string]string{"namespace": "default", "name": "my-tmpl"}) + handler.HandleGetWorkflowTemplate(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var resp api.StandardResponse[v1alpha2.WorkflowTemplate] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, "my-tmpl", resp.Data.Name) + }) + + t.Run("not found", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + req := httptest.NewRequest("GET", "/api/workflow-templates/default/missing", nil) + req = workflowSetUser(req, "test-user") + req = withVars(req, map[string]string{"namespace": "default", "name": "missing"}) + handler.HandleGetWorkflowTemplate(recorder, req) + + require.Equal(t, http.StatusNotFound, recorder.Code) + }) +} + +func TestWorkflowsHandler_CreateRun(t *testing.T) { + t.Run("success", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + body, _ := json.Marshal(api.CreateWorkflowRunRequest{ + Name: "run-1", + Namespace: "default", + WorkflowTemplateRef: "my-template", + Params: []v1alpha2.Param{{Name: "env", Value: "prod"}}, + }) + req := httptest.NewRequest("POST", "/api/workflow-runs", bytes.NewReader(body)) + req = workflowSetUser(req, "test-user") + handler.HandleCreateWorkflowRun(recorder, req) + + require.Equal(t, http.StatusCreated, recorder.Code) + var resp api.StandardResponse[v1alpha2.WorkflowRun] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, "run-1", resp.Data.Name) + require.Equal(t, "my-template", resp.Data.Spec.WorkflowTemplateRef) + require.Len(t, resp.Data.Spec.Params, 1) + }) + + t.Run("missing name", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + body, _ := json.Marshal(api.CreateWorkflowRunRequest{ + WorkflowTemplateRef: "my-template", + }) + req := httptest.NewRequest("POST", "/api/workflow-runs", bytes.NewReader(body)) + req = workflowSetUser(req, "test-user") + handler.HandleCreateWorkflowRun(recorder, req) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("missing templateRef", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + body, _ := json.Marshal(api.CreateWorkflowRunRequest{ + Name: "run-1", + Namespace: "default", + }) + req := httptest.NewRequest("POST", "/api/workflow-runs", bytes.NewReader(body)) + req = workflowSetUser(req, "test-user") + handler.HandleCreateWorkflowRun(recorder, req) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("conflict", func(t *testing.T) { + existing := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "run-1", Namespace: "default"}, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-template", + }, + } + handler, _, recorder := setupWorkflowsHandler(existing) + + body, _ := json.Marshal(api.CreateWorkflowRunRequest{ + Name: "run-1", + Namespace: "default", + WorkflowTemplateRef: "my-template", + }) + req := httptest.NewRequest("POST", "/api/workflow-runs", bytes.NewReader(body)) + req = workflowSetUser(req, "test-user") + handler.HandleCreateWorkflowRun(recorder, req) + + require.Equal(t, http.StatusConflict, recorder.Code) + }) +} + +func TestWorkflowsHandler_ListRuns(t *testing.T) { + t.Run("returns runs", func(t *testing.T) { + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "run-1", + Namespace: "default", + Labels: map[string]string{"kagent.dev/workflow-template": "my-tmpl"}, + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: "my-tmpl", + }, + } + handler, _, recorder := setupWorkflowsHandler(run) + + req := httptest.NewRequest("GET", "/api/workflow-runs", nil) + req = workflowSetUser(req, "test-user") + handler.HandleListWorkflowRuns(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var resp api.StandardResponse[[]v1alpha2.WorkflowRun] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + }) + + t.Run("filter by status", func(t *testing.T) { + run1 := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "run-1", Namespace: "default"}, + Spec: v1alpha2.WorkflowRunSpec{WorkflowTemplateRef: "t"}, + Status: v1alpha2.WorkflowRunStatus{Phase: "Running"}, + } + run2 := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "run-2", Namespace: "default"}, + Spec: v1alpha2.WorkflowRunSpec{WorkflowTemplateRef: "t"}, + Status: v1alpha2.WorkflowRunStatus{Phase: "Succeeded"}, + } + handler, _, recorder := setupWorkflowsHandler(run1, run2) + + req := httptest.NewRequest("GET", "/api/workflow-runs?status=Running", nil) + req = workflowSetUser(req, "test-user") + handler.HandleListWorkflowRuns(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var resp api.StandardResponse[[]v1alpha2.WorkflowRun] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + require.Equal(t, "run-1", resp.Data[0].Name) + }) +} + +func TestWorkflowsHandler_GetRun(t *testing.T) { + t.Run("found with step statuses", func(t *testing.T) { + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "run-1", Namespace: "default"}, + Spec: v1alpha2.WorkflowRunSpec{WorkflowTemplateRef: "t"}, + Status: v1alpha2.WorkflowRunStatus{ + Phase: "Running", + Steps: []v1alpha2.StepStatus{ + {Name: "step-a", Phase: v1alpha2.StepPhaseSucceeded}, + {Name: "step-b", Phase: v1alpha2.StepPhaseRunning}, + }, + }, + } + handler, _, recorder := setupWorkflowsHandler(run) + + req := httptest.NewRequest("GET", "/api/workflow-runs/default/run-1", nil) + req = workflowSetUser(req, "test-user") + req = withVars(req, map[string]string{"namespace": "default", "name": "run-1"}) + handler.HandleGetWorkflowRun(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var resp api.StandardResponse[v1alpha2.WorkflowRun] + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Len(t, resp.Data.Status.Steps, 2) + }) + + t.Run("not found", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + req := httptest.NewRequest("GET", "/api/workflow-runs/default/missing", nil) + req = workflowSetUser(req, "test-user") + req = withVars(req, map[string]string{"namespace": "default", "name": "missing"}) + handler.HandleGetWorkflowRun(recorder, req) + + require.Equal(t, http.StatusNotFound, recorder.Code) + }) +} + +func TestWorkflowsHandler_DeleteRun(t *testing.T) { + t.Run("success", func(t *testing.T) { + run := &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "run-1", Namespace: "default"}, + Spec: v1alpha2.WorkflowRunSpec{WorkflowTemplateRef: "t"}, + } + handler, _, recorder := setupWorkflowsHandler(run) + + req := httptest.NewRequest("DELETE", "/api/workflow-runs/default/run-1", nil) + req = workflowSetUser(req, "test-user") + req = withVars(req, map[string]string{"namespace": "default", "name": "run-1"}) + handler.HandleDeleteWorkflowRun(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("not found", func(t *testing.T) { + handler, _, recorder := setupWorkflowsHandler() + + req := httptest.NewRequest("DELETE", "/api/workflow-runs/default/missing", nil) + req = workflowSetUser(req, "test-user") + req = withVars(req, map[string]string{"namespace": "default", "name": "missing"}) + handler.HandleDeleteWorkflowRun(recorder, req) + + require.Equal(t, http.StatusNotFound, recorder.Code) + }) +} diff --git a/go/core/internal/httpserver/server.go b/go/core/internal/httpserver/server.go index 350770e67..37949bea7 100644 --- a/go/core/internal/httpserver/server.go +++ b/go/core/internal/httpserver/server.go @@ -42,6 +42,12 @@ const ( APIPathFeedback = "/api/feedback" APIPathLangGraph = "/api/langgraph" APIPathCrewAI = "/api/crewai" + APIPathCronJobs = "/api/cronjobs" + APIPathGitRepos = "/api/gitrepos" + APIPathPlugins = "/api/plugins" + APIPathDashboard = "/api/dashboard" + APIPathWorkflowTemplates = "/api/workflow-templates" + APIPathWorkflowRuns = "/api/workflow-runs" ) var defaultModelConfig = types.NamespacedName{ @@ -62,6 +68,7 @@ type ServerConfig struct { Authorizer auth.Authorizer ProxyURL string Reconciler reconciler.KagentReconciler + GitRepoMCPURL string } // HTTPServer is the structure that manages the HTTP server @@ -81,7 +88,7 @@ func NewHTTPServer(config ServerConfig) (*HTTPServer, error) { return &HTTPServer{ config: config, router: config.Router, - handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler), + handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL, config.Reconciler, config.GitRepoMCPURL), authenticator: config.Authenticator, }, nil } @@ -271,6 +278,44 @@ func (s *HTTPServer) setupRoutes() { s.router.HandleFunc(APIPathCrewAI+"/flows/state", adaptHandler(s.handlers.CrewAI.HandleStoreFlowState)).Methods(http.MethodPost) s.router.HandleFunc(APIPathCrewAI+"/flows/state", adaptHandler(s.handlers.CrewAI.HandleGetFlowState)).Methods(http.MethodGet) + // AgentCronJobs + s.router.HandleFunc(APIPathCronJobs, adaptHandler(s.handlers.AgentCronJobs.HandleListCronJobs)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathCronJobs, adaptHandler(s.handlers.AgentCronJobs.HandleCreateCronJob)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathCronJobs+"/{namespace}/{name}", adaptHandler(s.handlers.AgentCronJobs.HandleGetCronJob)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathCronJobs+"/{namespace}/{name}", adaptHandler(s.handlers.AgentCronJobs.HandleUpdateCronJob)).Methods(http.MethodPut) + s.router.HandleFunc(APIPathCronJobs+"/{namespace}/{name}", adaptHandler(s.handlers.AgentCronJobs.HandleDeleteCronJob)).Methods(http.MethodDelete) + + // Git Repos (proxy to gitrepo-mcp) + s.router.HandleFunc(APIPathGitRepos+"/search", adaptHandler(s.handlers.GitRepos.HandleSearchAll)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathGitRepos+"/{name}/sync", adaptHandler(s.handlers.GitRepos.HandleSyncRepo)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathGitRepos+"/{name}/index", adaptHandler(s.handlers.GitRepos.HandleIndexRepo)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathGitRepos+"/{name}/search", adaptHandler(s.handlers.GitRepos.HandleSearchRepo)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathGitRepos+"/{name}", adaptHandler(s.handlers.GitRepos.HandleGetRepo)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathGitRepos+"/{name}", adaptHandler(s.handlers.GitRepos.HandleDeleteRepo)).Methods(http.MethodDelete) + s.router.HandleFunc(APIPathGitRepos, adaptHandler(s.handlers.GitRepos.HandleListRepos)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathGitRepos, adaptHandler(s.handlers.GitRepos.HandleAddRepo)).Methods(http.MethodPost) + + // Dashboard + s.router.HandleFunc(APIPathDashboard+"/stats", adaptHandler(s.handlers.Dashboard.HandleDashboardStats)).Methods(http.MethodGet) + + // Workflow Templates + s.router.HandleFunc(APIPathWorkflowTemplates, adaptHandler(s.handlers.Workflows.HandleListWorkflowTemplates)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathWorkflowTemplates+"/{namespace}/{name}", adaptHandler(s.handlers.Workflows.HandleGetWorkflowTemplate)).Methods(http.MethodGet) + + // Workflow Runs + s.router.HandleFunc(APIPathWorkflowRuns, adaptHandler(s.handlers.Workflows.HandleListWorkflowRuns)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathWorkflowRuns, adaptHandler(s.handlers.Workflows.HandleCreateWorkflowRun)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathWorkflowRuns+"/{namespace}/{name}", adaptHandler(s.handlers.Workflows.HandleGetWorkflowRun)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathWorkflowRuns+"/{namespace}/{name}", adaptHandler(s.handlers.Workflows.HandleDeleteWorkflowRun)).Methods(http.MethodDelete) + + // Plugins + s.router.HandleFunc(APIPathPlugins, adaptHandler(s.handlers.Plugins.HandleListPlugins)).Methods(http.MethodGet) + + // Plugin reverse proxy (catch-all, must be registered after more specific routes) + // Uses /_p/ prefix to avoid conflict with Next.js /plugins/ browser URLs + // Uses raw http.HandlerFunc, not adaptHandler, because it proxies directly + s.router.PathPrefix("/_p/{name}").HandlerFunc(s.handlers.PluginProxy.HandleProxy) + // A2A s.router.PathPrefix(APIPathA2A + "/{namespace}/{name}").Handler(s.config.A2AHandler) diff --git a/go/core/internal/temporal/workflow/action_activity.go b/go/core/internal/temporal/workflow/action_activity.go new file mode 100644 index 000000000..30627bb40 --- /dev/null +++ b/go/core/internal/temporal/workflow/action_activity.go @@ -0,0 +1,44 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "context" + "fmt" + + "go.temporal.io/sdk/temporal" +) + +// DAGActivities holds the activity implementations for DAG workflow steps. +type DAGActivities struct { + Registry *ActionRegistry +} + +// ActionActivity dispatches a step action to the registered handler. +func (a *DAGActivities) ActionActivity(ctx context.Context, req *ActionRequest) (*ActionResult, error) { + if req == nil { + return nil, temporal.NewNonRetryableApplicationError("nil action request", "INVALID_REQUEST", nil) + } + + handler, ok := a.Registry.Get(req.Action) + if !ok { + return nil, temporal.NewNonRetryableApplicationError( + fmt.Sprintf("unknown action: %s", req.Action), "UNKNOWN_ACTION", nil) + } + + return handler.Execute(ctx, req.Inputs) +} diff --git a/go/core/internal/temporal/workflow/action_activity_test.go b/go/core/internal/temporal/workflow/action_activity_test.go new file mode 100644 index 000000000..bad8fd9df --- /dev/null +++ b/go/core/internal/temporal/workflow/action_activity_test.go @@ -0,0 +1,317 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "go.temporal.io/sdk/temporal" +) + +func TestActionRegistry(t *testing.T) { + t.Run("register and get handler", func(t *testing.T) { + r := NewActionRegistry() + handler := ActionHandlerFunc(func(_ context.Context, inputs map[string]string) (*ActionResult, error) { + return &ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil + }) + r.Register("test.action", handler) + + got, ok := r.Get("test.action") + if !ok { + t.Fatal("expected handler to be found") + } + result, err := got.Execute(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result.Output) != `{"ok":true}` { + t.Errorf("got output %s, want {\"ok\":true}", result.Output) + } + }) + + t.Run("get unknown handler", func(t *testing.T) { + r := NewActionRegistry() + _, ok := r.Get("nonexistent") + if ok { + t.Error("expected handler to not be found") + } + }) +} + +func TestActionActivity(t *testing.T) { + tests := []struct { + name string + req *ActionRequest + handlers map[string]ActionHandler + wantOutput string + wantErr bool + wantErrMsg string + }{ + { + name: "dispatches to correct handler", + req: &ActionRequest{Action: "test.action", Inputs: map[string]string{"key": "value"}}, + handlers: map[string]ActionHandler{ + "test.action": ActionHandlerFunc(func(_ context.Context, inputs map[string]string) (*ActionResult, error) { + out, _ := json.Marshal(inputs) + return &ActionResult{Output: out}, nil + }), + }, + wantOutput: `{"key":"value"}`, + }, + { + name: "unknown action returns NonRetryableApplicationError", + req: &ActionRequest{Action: "unknown.action"}, + handlers: map[string]ActionHandler{}, + wantErr: true, + wantErrMsg: "unknown action: unknown.action", + }, + { + name: "nil request returns NonRetryableApplicationError", + req: nil, + handlers: map[string]ActionHandler{}, + wantErr: true, + wantErrMsg: "nil action request", + }, + { + name: "handler error propagates", + req: &ActionRequest{Action: "fail.action"}, + handlers: map[string]ActionHandler{ + "fail.action": ActionHandlerFunc(func(_ context.Context, _ map[string]string) (*ActionResult, error) { + return nil, fmt.Errorf("handler failed") + }), + }, + wantErr: true, + wantErrMsg: "handler failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := NewActionRegistry() + for name, h := range tt.handlers { + registry.Register(name, h) + } + activities := &DAGActivities{Registry: registry} + + result, err := activities.ActionActivity(context.Background(), tt.req) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.wantErrMsg != "" { + // Check for NonRetryableApplicationError + var appErr *temporal.ApplicationError + if ok := temporal.IsApplicationError(err); ok { + if err.Error() != tt.wantErrMsg { + // Application errors wrap the message + } + } else { + _ = appErr // suppress unused + if err.Error() != tt.wantErrMsg { + t.Errorf("error = %q, want %q", err.Error(), tt.wantErrMsg) + } + } + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantOutput != "" && string(result.Output) != tt.wantOutput { + t.Errorf("output = %s, want %s", result.Output, tt.wantOutput) + } + }) + } +} + +func TestNoopHandler(t *testing.T) { + handler := &NoopHandler{} + + t.Run("returns inputs as output", func(t *testing.T) { + inputs := map[string]string{"foo": "bar", "baz": "qux"} + result, err := handler.Execute(context.Background(), inputs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Error != "" { + t.Fatalf("unexpected result error: %s", result.Error) + } + + var got map[string]string + if err := json.Unmarshal(result.Output, &got); err != nil { + t.Fatalf("failed to unmarshal output: %v", err) + } + if got["foo"] != "bar" || got["baz"] != "qux" { + t.Errorf("got %v, want map with foo=bar, baz=qux", got) + } + }) + + t.Run("nil inputs returns empty object", func(t *testing.T) { + result, err := handler.Execute(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(result.Output) != "null" { + t.Errorf("got %s, want null", result.Output) + } + }) +} + +func TestHTTPRequestHandler(t *testing.T) { + t.Run("GET request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"message":"hello"}`) + })) + defer server.Close() + + handler := &HTTPRequestHandler{Client: server.Client()} + result, err := handler.Execute(context.Background(), map[string]string{ + "url": server.URL, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Error != "" { + t.Fatalf("unexpected result error: %s", result.Error) + } + + var out map[string]interface{} + if err := json.Unmarshal(result.Output, &out); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if out["status_code"].(float64) != 200 { + t.Errorf("status_code = %v, want 200", out["status_code"]) + } + if out["body"] != `{"message":"hello"}` { + t.Errorf("body = %v, want {\"message\":\"hello\"}", out["body"]) + } + }) + + t.Run("POST request with body", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected application/json content type, got %s", r.Header.Get("Content-Type")) + } + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"123"}`) + })) + defer server.Close() + + handler := &HTTPRequestHandler{Client: server.Client()} + result, err := handler.Execute(context.Background(), map[string]string{ + "url": server.URL, + "method": "POST", + "body": `{"name":"test"}`, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Error != "" { + t.Fatalf("unexpected result error: %s", result.Error) + } + + var out map[string]interface{} + json.Unmarshal(result.Output, &out) + if out["status_code"].(float64) != 201 { + t.Errorf("status_code = %v, want 201", out["status_code"]) + } + }) + + t.Run("HTTP error status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "not found") + })) + defer server.Close() + + handler := &HTTPRequestHandler{Client: server.Client()} + result, err := handler.Execute(context.Background(), map[string]string{ + "url": server.URL, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Error == "" { + t.Error("expected error for 404 status") + } + // Output should still be populated + if result.Output == nil { + t.Error("expected output to be populated even on HTTP error") + } + }) + + t.Run("missing URL", func(t *testing.T) { + handler := &HTTPRequestHandler{Client: http.DefaultClient} + result, err := handler.Execute(context.Background(), map[string]string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Error == "" { + t.Error("expected error for missing URL") + } + }) + + t.Run("custom content type", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "text/plain" { + t.Errorf("expected text/plain, got %s", r.Header.Get("Content-Type")) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + })) + defer server.Close() + + handler := &HTTPRequestHandler{Client: server.Client()} + result, err := handler.Execute(context.Background(), map[string]string{ + "url": server.URL, + "method": "POST", + "body": "hello", + "content_type": "text/plain", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Error != "" { + t.Fatalf("unexpected result error: %s", result.Error) + } + }) +} + +func TestRegisterBuiltinHandlers(t *testing.T) { + r := NewActionRegistry() + RegisterBuiltinHandlers(r) + + for _, name := range []string{"noop", "http.request"} { + if _, ok := r.Get(name); !ok { + t.Errorf("expected built-in handler %q to be registered", name) + } + } +} diff --git a/go/core/internal/temporal/workflow/action_handlers.go b/go/core/internal/temporal/workflow/action_handlers.go new file mode 100644 index 000000000..20a83d286 --- /dev/null +++ b/go/core/internal/temporal/workflow/action_handlers.go @@ -0,0 +1,114 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// RegisterBuiltinHandlers registers all built-in action handlers on the registry. +func RegisterBuiltinHandlers(r *ActionRegistry) { + r.Register("noop", &NoopHandler{}) + r.Register("http.request", &HTTPRequestHandler{ + Client: http.DefaultClient, + }) +} + +// NoopHandler returns inputs as outputs (for testing/placeholder steps). +type NoopHandler struct{} + +// Execute returns the inputs as a JSON object output. +func (h *NoopHandler) Execute(_ context.Context, inputs map[string]string) (*ActionResult, error) { + out, err := json.Marshal(inputs) + if err != nil { + return nil, fmt.Errorf("noop: failed to marshal inputs: %w", err) + } + return &ActionResult{Output: out}, nil +} + +// HTTPRequestHandler makes HTTP requests. +type HTTPRequestHandler struct { + Client *http.Client +} + +// Execute makes an HTTP request based on the inputs. +// Supported input keys: +// - url (required): the request URL +// - method: HTTP method (default: GET) +// - body: request body (for POST/PUT/PATCH) +// - content_type: Content-Type header (default: application/json for requests with body) +func (h *HTTPRequestHandler) Execute(ctx context.Context, inputs map[string]string) (*ActionResult, error) { + rawURL, ok := inputs["url"] + if !ok || rawURL == "" { + return &ActionResult{Error: "missing required input: url"}, nil + } + + method := strings.ToUpper(inputs["method"]) + if method == "" { + method = http.MethodGet + } + + var bodyReader io.Reader + if body, ok := inputs["body"]; ok && body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader) + if err != nil { + return &ActionResult{Error: fmt.Sprintf("failed to create request: %v", err)}, nil + } + + if ct, ok := inputs["content_type"]; ok && ct != "" { + req.Header.Set("Content-Type", ct) + } else if bodyReader != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := h.Client.Do(req) + if err != nil { + return &ActionResult{Error: fmt.Sprintf("request failed: %v", err)}, nil + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return &ActionResult{Error: fmt.Sprintf("failed to read response: %v", err)}, nil + } + + output := map[string]interface{}{ + "status_code": resp.StatusCode, + "body": string(respBody), + } + out, err := json.Marshal(output) + if err != nil { + return nil, fmt.Errorf("http.request: failed to marshal response: %w", err) + } + + if resp.StatusCode >= 400 { + return &ActionResult{ + Output: out, + Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)), + }, nil + } + + return &ActionResult{Output: out}, nil +} diff --git a/go/core/internal/temporal/workflow/action_registry.go b/go/core/internal/temporal/workflow/action_registry.go new file mode 100644 index 000000000..2df5f2c62 --- /dev/null +++ b/go/core/internal/temporal/workflow/action_registry.go @@ -0,0 +1,63 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "context" + "sync" +) + +// ActionHandler is the interface that action implementations must satisfy. +type ActionHandler interface { + Execute(ctx context.Context, inputs map[string]string) (*ActionResult, error) +} + +// ActionHandlerFunc is an adapter to allow use of ordinary functions as ActionHandlers. +type ActionHandlerFunc func(ctx context.Context, inputs map[string]string) (*ActionResult, error) + +// Execute calls f(ctx, inputs). +func (f ActionHandlerFunc) Execute(ctx context.Context, inputs map[string]string) (*ActionResult, error) { + return f(ctx, inputs) +} + +// ActionRegistry holds named action handlers. +type ActionRegistry struct { + mu sync.RWMutex + handlers map[string]ActionHandler +} + +// NewActionRegistry creates a new empty ActionRegistry. +func NewActionRegistry() *ActionRegistry { + return &ActionRegistry{ + handlers: make(map[string]ActionHandler), + } +} + +// Register adds a handler for the given action name. +func (r *ActionRegistry) Register(name string, handler ActionHandler) { + r.mu.Lock() + defer r.mu.Unlock() + r.handlers[name] = handler +} + +// Get returns the handler for the given action name. +func (r *ActionRegistry) Get(name string) (ActionHandler, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + h, ok := r.handlers[name] + return h, ok +} diff --git a/go/core/internal/temporal/workflow/agent_step.go b/go/core/internal/temporal/workflow/agent_step.go new file mode 100644 index 000000000..caa72d679 --- /dev/null +++ b/go/core/internal/temporal/workflow/agent_step.go @@ -0,0 +1,141 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "encoding/json" + "fmt" + + "github.com/kagent-dev/kagent/go/core/internal/compiler" + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/workflow" +) + +// AgentStepRequest is the input sent to the agent child workflow. +// Fields are compatible with the ADK's ExecutionRequest (same JSON tags) +// so the agent worker can deserialize it without importing go/core. +type AgentStepRequest struct { + SessionID string `json:"sessionID"` + AgentName string `json:"agentName"` + Message []byte `json:"message"` +} + +// AgentStepResult is the output received from the agent child workflow. +// Fields are compatible with the ADK's ExecutionResult. +type AgentStepResult struct { + SessionID string `json:"sessionID"` + Status string `json:"status"` // "completed", "rejected", "failed" + Response []byte `json:"response,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// buildAgentChildOptions creates ChildWorkflowOptions for an agent step. +func buildAgentChildOptions(parentWorkflowID, stepName, agentRef string) workflow.ChildWorkflowOptions { + return workflow.ChildWorkflowOptions{ + WorkflowID: fmt.Sprintf("%s:agent:%s", parentWorkflowID, stepName), + TaskQueue: agentRef, + ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_REQUEST_CANCEL, + } +} + +// buildAgentMessage constructs the A2A-compatible JSON message for the agent. +// The message contains the prompt and any additional inputs as context. +func buildAgentMessage(prompt string, inputs map[string]string) ([]byte, error) { + msg := map[string]interface{}{ + "prompt": prompt, + } + if len(inputs) > 0 { + msg["context"] = inputs + } + return json.Marshal(msg) +} + +// executeAgentStep dispatches an agent step as a Temporal child workflow. +// It renders the prompt, builds the agent request, executes the child workflow, +// and maps the agent response to a JSON output. +func executeAgentStep( + ctx workflow.Context, + step compiler.ExecutionStep, + prompt string, + inputs map[string]string, + plan *compiler.ExecutionPlan, +) (json.RawMessage, error) { + // Build child workflow options. + childOpts := buildAgentChildOptions(plan.WorkflowID, step.Name, step.AgentRef) + childCtx := workflow.WithChildOptions(ctx, childOpts) + + // Build the agent message. + message, err := buildAgentMessage(prompt, inputs) + if err != nil { + return nil, fmt.Errorf("failed to build agent message: %w", err) + } + + // Build execution request. + req := &AgentStepRequest{ + SessionID: fmt.Sprintf("dag-%s-%s", plan.WorkflowID, step.Name), + AgentName: step.AgentRef, + Message: message, + } + + // Execute the child workflow targeting the agent's task queue. + var result AgentStepResult + err = workflow.ExecuteChildWorkflow(childCtx, "AgentExecutionWorkflow", req).Get(childCtx, &result) + if err != nil { + return nil, fmt.Errorf("agent %q failed: %w", step.AgentRef, err) + } + + // Check agent-level failure. + if result.Status == "failed" || result.Status == "rejected" { + reason := result.Reason + if reason == "" { + reason = "agent returned status: " + result.Status + } + return nil, fmt.Errorf("agent %q %s: %s", step.AgentRef, result.Status, reason) + } + + // Map the agent response to output. + return mapAgentOutput(result.Response, step.AgentRef) +} + +// mapAgentOutput converts the raw agent response bytes into a JSON output. +// If the response is valid JSON, it's returned as-is. Otherwise it's wrapped +// as {"response": ""}. +func mapAgentOutput(response []byte, agentRef string) (json.RawMessage, error) { + if len(response) == 0 { + return json.RawMessage(`{}`), nil + } + + // Check if response is already valid JSON object or array. + if json.Valid(response) { + var probe interface{} + if err := json.Unmarshal(response, &probe); err == nil { + // If it's a map or slice, return as-is. + switch probe.(type) { + case map[string]interface{}, []interface{}: + return response, nil + } + } + } + + // Wrap non-object responses as {"response": "..."} + wrapped := map[string]string{"response": string(response)} + out, err := json.Marshal(wrapped) + if err != nil { + return nil, fmt.Errorf("failed to marshal agent %q response: %w", agentRef, err) + } + return out, nil +} diff --git a/go/core/internal/temporal/workflow/agent_step_test.go b/go/core/internal/temporal/workflow/agent_step_test.go new file mode 100644 index 000000000..06ec4aa5c --- /dev/null +++ b/go/core/internal/temporal/workflow/agent_step_test.go @@ -0,0 +1,381 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "encoding/json" + "testing" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/testsuite" + "go.temporal.io/sdk/workflow" +) + +// stubAgentWorkflow is a stub for registering the child workflow in the test env. +func stubAgentWorkflow(_ workflow.Context, _ *AgentStepRequest) (*AgentStepResult, error) { + return nil, nil +} + +func TestAgentStepInDAGWorkflow(t *testing.T) { + tests := []struct { + name string + plan *compiler.ExecutionPlan + mockSetup func(env *testsuite.TestWorkflowEnvironment) + expectedStatus string + expectedPhases map[string]string + checkOutput func(t *testing.T, result *DAGResult) + }{ + { + name: "agent step with successful response", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-agent", + TaskQueue: "kagent-workflows", + Params: map[string]string{"topic": "testing"}, + Steps: []compiler.ExecutionStep{ + { + Name: "analyze", + Type: v1alpha2.StepTypeAgent, + AgentRef: "my-agent", + Prompt: "Analyze ${{ params.topic }}", + Output: &v1alpha2.StepOutput{ + Keys: map[string]string{"summary": "summary"}, + }, + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnWorkflow("AgentExecutionWorkflow", mock.Anything, mock.Anything).Return( + &AgentStepResult{ + SessionID: "dag-wf-test-agent-analyze", + Status: "completed", + Response: []byte(`{"summary":"all good","details":"no issues found"}`), + }, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "analyze": "Succeeded", + }, + checkOutput: func(t *testing.T, result *DAGResult) { + require.Equal(t, "all good", result.Output["summary"]) + }, + }, + { + name: "agent step with failed status", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-agent-fail", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "failing-agent", + Type: v1alpha2.StepTypeAgent, + AgentRef: "bad-agent", + Prompt: "do something", + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnWorkflow("AgentExecutionWorkflow", mock.Anything, mock.Anything).Return( + &AgentStepResult{ + Status: "failed", + Reason: "agent crashed", + }, nil, + ) + }, + expectedStatus: "failed", + expectedPhases: map[string]string{ + "failing-agent": "Failed", + }, + }, + { + name: "agent step with rejected status", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-agent-rejected", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "rejected-agent", + Type: v1alpha2.StepTypeAgent, + AgentRef: "strict-agent", + Prompt: "invalid request", + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnWorkflow("AgentExecutionWorkflow", mock.Anything, mock.Anything).Return( + &AgentStepResult{ + Status: "rejected", + Reason: "invalid input format", + }, nil, + ) + }, + expectedStatus: "failed", + expectedPhases: map[string]string{ + "rejected-agent": "Failed", + }, + }, + { + name: "agent step prompt rendered with context from prior step", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-agent-ctx", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "fetch", + Type: v1alpha2.StepTypeAction, + Action: "noop", + }, + { + Name: "agent-step", + Type: v1alpha2.StepTypeAgent, + AgentRef: "analyzer", + Prompt: "Analyze the data", + DependsOn: []string{"fetch"}, + Output: &v1alpha2.StepOutput{ + As: "analysis", + }, + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.Anything).Return( + &ActionResult{Output: json.RawMessage(`{"data":"hello"}`)}, nil, + ) + env.OnWorkflow("AgentExecutionWorkflow", mock.Anything, mock.Anything).Return( + &AgentStepResult{ + Status: "completed", + Response: []byte(`{"result":"analyzed"}`), + }, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "fetch": "Succeeded", + "agent-step": "Succeeded", + }, + }, + { + name: "agent step with empty response", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-agent-empty", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "empty-agent", + Type: v1alpha2.StepTypeAgent, + AgentRef: "silent-agent", + Prompt: "do something quiet", + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnWorkflow("AgentExecutionWorkflow", mock.Anything, mock.Anything).Return( + &AgentStepResult{ + Status: "completed", + Response: nil, + }, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "empty-agent": "Succeeded", + }, + }, + { + name: "agent step output keys mapping to globals", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-agent-keys", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "keyed-agent", + Type: v1alpha2.StepTypeAgent, + AgentRef: "data-agent", + Prompt: "extract info", + Output: &v1alpha2.StepOutput{ + Keys: map[string]string{ + "extracted_name": "name", + "extracted_email": "email", + }, + }, + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnWorkflow("AgentExecutionWorkflow", mock.Anything, mock.Anything).Return( + &AgentStepResult{ + Status: "completed", + Response: []byte(`{"name":"John","email":"john@example.com","extra":"ignored"}`), + }, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "keyed-agent": "Succeeded", + }, + checkOutput: func(t *testing.T, result *DAGResult) { + require.Equal(t, "John", result.Output["extracted_name"]) + require.Equal(t, "john@example.com", result.Output["extracted_email"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + + // Register stubs for activities and child workflows. + env.RegisterActivityWithOptions(stubActionActivity, activity.RegisterOptions{Name: "ActionActivity"}) + env.RegisterWorkflowWithOptions(stubAgentWorkflow, workflow.RegisterOptions{Name: "AgentExecutionWorkflow"}) + + tt.mockSetup(env) + + env.ExecuteWorkflow(DAGWorkflow, tt.plan) + + require.True(t, env.IsWorkflowCompleted()) + + err := env.GetWorkflowError() + require.NoError(t, err) + + var result DAGResult + require.NoError(t, env.GetWorkflowResult(&result)) + require.Equal(t, tt.expectedStatus, result.Status) + + for _, sr := range result.Steps { + expected, ok := tt.expectedPhases[sr.Name] + if ok { + require.Equal(t, expected, sr.Phase, "step %q phase mismatch", sr.Name) + } + } + + if tt.checkOutput != nil { + tt.checkOutput(t, &result) + } + }) + } +} + +func TestBuildAgentChildOptions(t *testing.T) { + opts := buildAgentChildOptions("wf-ns-tpl-run", "analyze", "my-agent") + require.Equal(t, "wf-ns-tpl-run:agent:analyze", opts.WorkflowID) + require.Equal(t, "my-agent", opts.TaskQueue) +} + +func TestBuildAgentMessage(t *testing.T) { + tests := []struct { + name string + prompt string + inputs map[string]string + check func(t *testing.T, msg []byte) + }{ + { + name: "prompt only", + prompt: "Hello world", + inputs: nil, + check: func(t *testing.T, msg []byte) { + var m map[string]interface{} + require.NoError(t, json.Unmarshal(msg, &m)) + require.Equal(t, "Hello world", m["prompt"]) + _, hasCtx := m["context"] + require.False(t, hasCtx) + }, + }, + { + name: "prompt with inputs", + prompt: "Analyze this", + inputs: map[string]string{"key": "value"}, + check: func(t *testing.T, msg []byte) { + var m map[string]interface{} + require.NoError(t, json.Unmarshal(msg, &m)) + require.Equal(t, "Analyze this", m["prompt"]) + ctx := m["context"].(map[string]interface{}) + require.Equal(t, "value", ctx["key"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := buildAgentMessage(tt.prompt, tt.inputs) + require.NoError(t, err) + tt.check(t, msg) + }) + } +} + +func TestMapAgentOutput(t *testing.T) { + tests := []struct { + name string + response []byte + check func(t *testing.T, output json.RawMessage) + }{ + { + name: "nil response returns empty object", + response: nil, + check: func(t *testing.T, output json.RawMessage) { + require.JSONEq(t, `{}`, string(output)) + }, + }, + { + name: "json object passed through", + response: []byte(`{"key":"value"}`), + check: func(t *testing.T, output json.RawMessage) { + require.JSONEq(t, `{"key":"value"}`, string(output)) + }, + }, + { + name: "json array passed through", + response: []byte(`[1,2,3]`), + check: func(t *testing.T, output json.RawMessage) { + require.JSONEq(t, `[1,2,3]`, string(output)) + }, + }, + { + name: "plain string wrapped", + response: []byte(`"hello"`), + check: func(t *testing.T, output json.RawMessage) { + require.JSONEq(t, `{"response":"\"hello\""}`, string(output)) + }, + }, + { + name: "non-json text wrapped", + response: []byte(`some plain text`), + check: func(t *testing.T, output json.RawMessage) { + require.JSONEq(t, `{"response":"some plain text"}`, string(output)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := mapAgentOutput(tt.response, "test-agent") + require.NoError(t, err) + tt.check(t, output) + }) + } +} + diff --git a/go/core/internal/temporal/workflow/dag_workflow.go b/go/core/internal/temporal/workflow/dag_workflow.go new file mode 100644 index 000000000..56d68d8be --- /dev/null +++ b/go/core/internal/temporal/workflow/dag_workflow.go @@ -0,0 +1,388 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "encoding/json" + "fmt" + "strconv" + "sync" + "time" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +const ( + // DAGStatusQueryType is the query name for retrieving step statuses. + DAGStatusQueryType = "dag-status" + + // defaultStartToClose is the default activity timeout. + defaultStartToClose = 5 * time.Minute +) + +// DAGResult holds the overall result of a DAG workflow execution. +type DAGResult struct { + Status string `json:"status"` // "succeeded" or "failed" + Steps []StepResult `json:"steps"` + Output map[string]string `json:"output,omitempty"` +} + +// StepResult holds the execution result of a single step. +type StepResult struct { + Name string `json:"name"` + Phase string `json:"phase"` + Output json.RawMessage `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Retries int32 `json:"retries,omitempty"` +} + +// ActionRequest is the input to the ActionActivity. +type ActionRequest struct { + Action string `json:"action"` + Inputs map[string]string `json:"inputs"` +} + +// ActionResult is the output of the ActionActivity. +type ActionResult struct { + Output json.RawMessage `json:"output"` + Error string `json:"error,omitempty"` +} + +// DAGWorkflow is the generic interpreter that executes an ExecutionPlan as a Temporal workflow. +func DAGWorkflow(ctx workflow.Context, plan *compiler.ExecutionPlan) (*DAGResult, error) { + if plan == nil { + return nil, fmt.Errorf("execution plan is nil") + } + + // Build workflow context for expression resolution. + wfCtx := &compiler.WorkflowContext{ + StepOutputs: make(map[string]json.RawMessage), + Globals: make(map[string]string), + } + // Extract workflow metadata from plan ID (format: wf-{namespace}-{template}-{run}). + wfCtx.WorkflowRunName = plan.WorkflowID + + // Thread-safe state for step tracking. + var mu sync.Mutex + completed := make(map[string]bool) + failed := make(map[string]bool) + stepPhases := make(map[string]string) + stepResults := make(map[string]*StepResult) + + // Initialize all steps as Pending. + for _, step := range plan.Steps { + stepPhases[step.Name] = string(v1alpha2.StepPhasePending) + stepResults[step.Name] = &StepResult{ + Name: step.Name, + Phase: string(v1alpha2.StepPhasePending), + } + } + + // Register query handler for status syncer. + if err := workflow.SetQueryHandler(ctx, DAGStatusQueryType, func() ([]StepResult, error) { + mu.Lock() + defer mu.Unlock() + results := make([]StepResult, 0, len(plan.Steps)) + for _, step := range plan.Steps { + results = append(results, *stepResults[step.Name]) + } + return results, nil + }); err != nil { + return nil, fmt.Errorf("failed to register query handler: %w", err) + } + + // Result channel: each step goroutine sends its result here. + resultCh := workflow.NewChannel(ctx) + + // Launch one goroutine per step. + for _, step := range plan.Steps { + step := step // capture loop variable + workflow.Go(ctx, func(gCtx workflow.Context) { + result := executeStep(gCtx, step, plan, wfCtx, &mu, completed, failed, stepPhases) + + mu.Lock() + stepResults[step.Name] = result + mu.Unlock() + + resultCh.Send(gCtx, result) + }) + } + + // Collect results from all steps. + allResults := make([]StepResult, 0, len(plan.Steps)) + for range plan.Steps { + var result StepResult + resultCh.Receive(ctx, &result) + allResults = append(allResults, result) + } + + // Determine overall status. + overallStatus := "succeeded" + for _, r := range allResults { + if r.Phase == string(v1alpha2.StepPhaseFailed) { + overallStatus = "failed" + break + } + } + + // Build output from globals. + mu.Lock() + output := make(map[string]string, len(wfCtx.Globals)) + for k, v := range wfCtx.Globals { + output[k] = v + } + mu.Unlock() + + return &DAGResult{ + Status: overallStatus, + Steps: allResults, + Output: output, + }, nil +} + +// executeStep runs a single step after waiting for its dependencies. +func executeStep( + ctx workflow.Context, + step compiler.ExecutionStep, + plan *compiler.ExecutionPlan, + wfCtx *compiler.WorkflowContext, + mu *sync.Mutex, + completed, failed map[string]bool, + stepPhases map[string]string, +) *StepResult { + result := &StepResult{Name: step.Name} + + // Wait for all dependencies to complete. + if len(step.DependsOn) > 0 { + _ = workflow.Await(ctx, func() bool { + mu.Lock() + defer mu.Unlock() + for _, dep := range step.DependsOn { + if !completed[dep] && !failed[dep] { + return false + } + } + return true + }) + } + + // Check if we should skip due to failed stop-mode dependencies. + mu.Lock() + shouldSkip := false + for _, dep := range step.DependsOn { + if failed[dep] { + // Find dep step to check onFailure mode. + for _, s := range plan.Steps { + if s.Name == dep { + onFailure := s.OnFailure + if onFailure == "" { + onFailure = "stop" + } + if onFailure == "stop" { + shouldSkip = true + } + break + } + } + } + } + if shouldSkip { + stepPhases[step.Name] = string(v1alpha2.StepPhaseSkipped) + completed[step.Name] = true + mu.Unlock() + result.Phase = string(v1alpha2.StepPhaseSkipped) + result.Error = "skipped: dependency failed" + return result + } + + // Mark as running. + stepPhases[step.Name] = string(v1alpha2.StepPhaseRunning) + mu.Unlock() + + // Resolve expressions in step inputs. + mu.Lock() + resolvedInputs, err := resolveStepInputs(step, plan.Params, wfCtx) + mu.Unlock() + if err != nil { + mu.Lock() + stepPhases[step.Name] = string(v1alpha2.StepPhaseFailed) + failed[step.Name] = true + mu.Unlock() + result.Phase = string(v1alpha2.StepPhaseFailed) + result.Error = fmt.Sprintf("expression resolution failed: %v", err) + return result + } + + // Configure activity options from step policy. + actOpts := buildActivityOptions(step.Policy) + actCtx := workflow.WithActivityOptions(ctx, actOpts) + + // Dispatch based on step type. + var output json.RawMessage + switch step.Type { + case v1alpha2.StepTypeAction: + output, err = executeActionStep(actCtx, step.Action, resolvedInputs) + case v1alpha2.StepTypeAgent: + mu.Lock() + resolvedPrompt, promptErr := compiler.ResolveExpression(step.Prompt, plan.Params, wfCtx) + mu.Unlock() + if promptErr != nil { + err = fmt.Errorf("prompt resolution failed: %w", promptErr) + } else { + output, err = executeAgentStep(ctx, step, resolvedPrompt, resolvedInputs, plan) + } + default: + err = fmt.Errorf("unknown step type: %q", step.Type) + } + + // Store results. + mu.Lock() + defer mu.Unlock() + + if err != nil { + stepPhases[step.Name] = string(v1alpha2.StepPhaseFailed) + failed[step.Name] = true + result.Phase = string(v1alpha2.StepPhaseFailed) + result.Error = err.Error() + } else { + stepPhases[step.Name] = string(v1alpha2.StepPhaseSucceeded) + completed[step.Name] = true + result.Phase = string(v1alpha2.StepPhaseSucceeded) + result.Output = output + + // Store output in workflow context. + storeStepOutput(step, output, wfCtx) + } + + return result +} + +// resolveStepInputs resolves all ${{ }} expressions in step input values. +// Caller must hold mu lock. +func resolveStepInputs(step compiler.ExecutionStep, params map[string]string, wfCtx *compiler.WorkflowContext) (map[string]string, error) { + if len(step.With) == 0 { + return nil, nil + } + resolved := make(map[string]string, len(step.With)) + for k, v := range step.With { + val, err := compiler.ResolveExpression(v, params, wfCtx) + if err != nil { + return nil, fmt.Errorf("input %q: %w", k, err) + } + resolved[k] = val + } + return resolved, nil +} + +// storeStepOutput stores step output in the workflow context using the step's output configuration. +// Caller must hold mu lock. +func storeStepOutput(step compiler.ExecutionStep, output json.RawMessage, wfCtx *compiler.WorkflowContext) { + if output == nil { + return + } + + // Store under alias or step name. + key := step.Name + if step.Output != nil && step.Output.As != "" { + key = step.Output.As + } + wfCtx.StepOutputs[key] = output + + // Map selected keys to globals. + if step.Output != nil && len(step.Output.Keys) > 0 { + var obj map[string]json.RawMessage + if err := json.Unmarshal(output, &obj); err == nil { + for globalKey, fieldPath := range step.Output.Keys { + if val, ok := obj[fieldPath]; ok { + var s string + if err := json.Unmarshal(val, &s); err == nil { + wfCtx.Globals[globalKey] = s + } else { + wfCtx.Globals[globalKey] = string(val) + } + } + } + } + } +} + +// executeActionStep dispatches an action step to the ActionActivity. +func executeActionStep(ctx workflow.Context, action string, inputs map[string]string) (json.RawMessage, error) { + req := &ActionRequest{ + Action: action, + Inputs: inputs, + } + var result ActionResult + err := workflow.ExecuteActivity(ctx, "ActionActivity", req).Get(ctx, &result) + if err != nil { + return nil, fmt.Errorf("action %q failed: %w", action, err) + } + if result.Error != "" { + return nil, fmt.Errorf("action %q returned error: %s", action, result.Error) + } + return result.Output, nil +} + +// buildActivityOptions creates Temporal ActivityOptions from a step policy. +func buildActivityOptions(policy *v1alpha2.StepPolicy) workflow.ActivityOptions { + opts := workflow.ActivityOptions{ + StartToCloseTimeout: defaultStartToClose, + } + + if policy == nil { + return opts + } + + if policy.Timeout != nil { + if policy.Timeout.StartToClose.Duration > 0 { + opts.StartToCloseTimeout = policy.Timeout.StartToClose.Duration + } + if policy.Timeout.ScheduleToClose != nil && policy.Timeout.ScheduleToClose.Duration > 0 { + opts.ScheduleToCloseTimeout = policy.Timeout.ScheduleToClose.Duration + } + if policy.Timeout.Heartbeat != nil && policy.Timeout.Heartbeat.Duration > 0 { + opts.HeartbeatTimeout = policy.Timeout.Heartbeat.Duration + } + } + + if policy.Retry != nil { + retryPolicy := &temporal.RetryPolicy{} + if policy.Retry.MaxAttempts > 0 { + retryPolicy.MaximumAttempts = policy.Retry.MaxAttempts + } + if policy.Retry.InitialInterval.Duration > 0 { + retryPolicy.InitialInterval = policy.Retry.InitialInterval.Duration + } + if policy.Retry.MaximumInterval.Duration > 0 { + retryPolicy.MaximumInterval = policy.Retry.MaximumInterval.Duration + } + if policy.Retry.BackoffCoefficient != "" { + if coeff, err := strconv.ParseFloat(policy.Retry.BackoffCoefficient, 64); err == nil { + retryPolicy.BackoffCoefficient = coeff + } + } + if len(policy.Retry.NonRetryableErrors) > 0 { + retryPolicy.NonRetryableErrorTypes = policy.Retry.NonRetryableErrors + } + opts.RetryPolicy = retryPolicy + } + + return opts +} diff --git a/go/core/internal/temporal/workflow/dag_workflow_test.go b/go/core/internal/temporal/workflow/dag_workflow_test.go new file mode 100644 index 000000000..30d852b2c --- /dev/null +++ b/go/core/internal/temporal/workflow/dag_workflow_test.go @@ -0,0 +1,415 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "context" + "encoding/json" + "testing" + "time" + + v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/kagent-dev/kagent/go/core/internal/compiler" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/testsuite" + "go.temporal.io/sdk/workflow" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// stubActionActivity is a stub used for registering the activity with the test environment. +func stubActionActivity(_ context.Context, _ *ActionRequest) (*ActionResult, error) { + return nil, nil +} + +func TestDAGWorkflow(t *testing.T) { + tests := []struct { + name string + plan *compiler.ExecutionPlan + mockSetup func(env *testsuite.TestWorkflowEnvironment) + expectedStatus string + expectedPhases map[string]string + checkOutput func(t *testing.T, result *DAGResult) + }{ + { + name: "linear DAG A->B->C executes in order", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-linear", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"a"}}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"b"}}, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.Anything).Return( + &ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "a": "Succeeded", + "b": "Succeeded", + "c": "Succeeded", + }, + }, + { + name: "parallel DAG A->[B,C]->D", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-parallel", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"a"}}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"a"}}, + {Name: "d", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"b", "c"}}, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.Anything).Return( + &ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "a": "Succeeded", + "b": "Succeeded", + "c": "Succeeded", + "d": "Succeeded", + }, + }, + { + name: "fail-fast: B fails with stop, C skipped", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-failfast", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "fail-action", DependsOn: []string{"a"}, OnFailure: "stop"}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"b"}}, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.MatchedBy(func(req *ActionRequest) bool { + return req.Action == "noop" + })).Return(&ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil) + + env.OnActivity("ActionActivity", mock.Anything, mock.MatchedBy(func(req *ActionRequest) bool { + return req.Action == "fail-action" + })).Return(&ActionResult{Error: "something went wrong"}, nil) + }, + expectedStatus: "failed", + expectedPhases: map[string]string{ + "a": "Succeeded", + "b": "Failed", + "c": "Skipped", + }, + }, + { + name: "continue-on-error: B fails with continue, C still runs", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-continue", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + {Name: "a", Type: v1alpha2.StepTypeAction, Action: "noop"}, + {Name: "b", Type: v1alpha2.StepTypeAction, Action: "fail-action", DependsOn: []string{"a"}, OnFailure: "continue"}, + {Name: "c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"b"}}, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.MatchedBy(func(req *ActionRequest) bool { + return req.Action == "noop" + })).Return(&ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil) + + env.OnActivity("ActionActivity", mock.Anything, mock.MatchedBy(func(req *ActionRequest) bool { + return req.Action == "fail-action" + })).Return(&ActionResult{Error: "something went wrong"}, nil) + }, + expectedStatus: "failed", + expectedPhases: map[string]string{ + "a": "Succeeded", + "b": "Failed", + "c": "Succeeded", + }, + }, + { + name: "context data flow: A output available to B", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-context", + TaskQueue: "kagent-workflows", + Params: map[string]string{"base": "http://example.com"}, + Steps: []compiler.ExecutionStep{ + { + Name: "fetch", + Type: v1alpha2.StepTypeAction, + Action: "http.request", + With: map[string]string{"url": "${{ params.base }}/api"}, + Output: &v1alpha2.StepOutput{ + Keys: map[string]string{"data_path": "path"}, + }, + }, + { + Name: "process", + Type: v1alpha2.StepTypeAction, + Action: "noop", + DependsOn: []string{"fetch"}, + With: map[string]string{"input": "${{ context.fetch.path }}"}, + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.MatchedBy(func(req *ActionRequest) bool { + return req.Action == "http.request" + })).Return(&ActionResult{Output: json.RawMessage(`{"path":"/src","status":"ok"}`)}, nil) + + env.OnActivity("ActionActivity", mock.Anything, mock.MatchedBy(func(req *ActionRequest) bool { + return req.Action == "noop" + })).Return(func(ctx context.Context, req *ActionRequest) (*ActionResult, error) { + // Echo inputs as output to verify context resolution. + inputJSON, _ := json.Marshal(req.Inputs) + return &ActionResult{Output: inputJSON}, nil + }) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "fetch": "Succeeded", + "process": "Succeeded", + }, + checkOutput: func(t *testing.T, result *DAGResult) { + // Verify globals were populated from output.keys. + require.Equal(t, "/src", result.Output["data_path"]) + }, + }, + { + name: "single step with no dependencies", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-single", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + {Name: "only", Type: v1alpha2.StepTypeAction, Action: "noop"}, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.Anything).Return( + &ActionResult{Output: json.RawMessage(`{"result":"done"}`)}, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "only": "Succeeded", + }, + }, + { + name: "nil plan returns error", + plan: nil, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + }, + expectedStatus: "", // workflow errors + }, + { + name: "step with custom retry and timeout policy", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-policy", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "with-policy", + Type: v1alpha2.StepTypeAction, + Action: "noop", + Policy: &v1alpha2.StepPolicy{ + Retry: &v1alpha2.WorkflowRetryPolicy{ + MaxAttempts: 5, + InitialInterval: metav1.Duration{Duration: 2 * time.Second}, + BackoffCoefficient: "3.0", + }, + Timeout: &v1alpha2.WorkflowTimeoutPolicy{ + StartToClose: metav1.Duration{Duration: 10 * time.Minute}, + }, + }, + }, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.Anything).Return( + &ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "with-policy": "Succeeded", + }, + }, + { + name: "diamond DAG with output alias", + plan: &compiler.ExecutionPlan{ + WorkflowID: "wf-test-diamond", + TaskQueue: "kagent-workflows", + Params: map[string]string{}, + Steps: []compiler.ExecutionStep{ + { + Name: "start", + Type: v1alpha2.StepTypeAction, + Action: "noop", + Output: &v1alpha2.StepOutput{As: "init"}, + }, + {Name: "left", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"start"}}, + {Name: "right", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"start"}}, + {Name: "join", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"left", "right"}}, + }, + }, + mockSetup: func(env *testsuite.TestWorkflowEnvironment) { + env.OnActivity("ActionActivity", mock.Anything, mock.Anything).Return( + &ActionResult{Output: json.RawMessage(`{"ok":true}`)}, nil, + ) + }, + expectedStatus: "succeeded", + expectedPhases: map[string]string{ + "start": "Succeeded", + "left": "Succeeded", + "right": "Succeeded", + "join": "Succeeded", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + env.RegisterActivityWithOptions(stubActionActivity, activity.RegisterOptions{Name: "ActionActivity"}) + + tt.mockSetup(env) + + env.ExecuteWorkflow(DAGWorkflow, tt.plan) + + if tt.plan == nil { + require.True(t, env.IsWorkflowCompleted()) + require.Error(t, env.GetWorkflowError()) + return + } + + require.True(t, env.IsWorkflowCompleted()) + + err := env.GetWorkflowError() + require.NoError(t, err) + + var result DAGResult + require.NoError(t, env.GetWorkflowResult(&result)) + require.Equal(t, tt.expectedStatus, result.Status) + + if tt.expectedPhases != nil { + for _, sr := range result.Steps { + expected, ok := tt.expectedPhases[sr.Name] + if ok { + require.Equal(t, expected, sr.Phase, "step %q phase mismatch", sr.Name) + } + } + } + + if tt.checkOutput != nil { + tt.checkOutput(t, &result) + } + }) + } +} + +func TestBuildActivityOptions(t *testing.T) { + tests := []struct { + name string + policy *v1alpha2.StepPolicy + check func(t *testing.T, opts workflow.ActivityOptions) + }{ + { + name: "nil policy uses defaults", + policy: nil, + check: func(t *testing.T, opts workflow.ActivityOptions) { + require.Equal(t, defaultStartToClose, opts.StartToCloseTimeout) + require.Nil(t, opts.RetryPolicy) + }, + }, + { + name: "custom timeout", + policy: &v1alpha2.StepPolicy{ + Timeout: &v1alpha2.WorkflowTimeoutPolicy{ + StartToClose: metav1.Duration{Duration: 10 * time.Minute}, + }, + }, + check: func(t *testing.T, opts workflow.ActivityOptions) { + require.Equal(t, 10*time.Minute, opts.StartToCloseTimeout) + }, + }, + { + name: "custom retry", + policy: &v1alpha2.StepPolicy{ + Retry: &v1alpha2.WorkflowRetryPolicy{ + MaxAttempts: 5, + InitialInterval: metav1.Duration{Duration: 2 * time.Second}, + BackoffCoefficient: "3.0", + }, + }, + check: func(t *testing.T, opts workflow.ActivityOptions) { + require.NotNil(t, opts.RetryPolicy) + require.Equal(t, int32(5), opts.RetryPolicy.MaximumAttempts) + require.Equal(t, 2*time.Second, opts.RetryPolicy.InitialInterval) + require.Equal(t, 3.0, opts.RetryPolicy.BackoffCoefficient) + }, + }, + { + name: "schedule-to-close and heartbeat", + policy: &v1alpha2.StepPolicy{ + Timeout: &v1alpha2.WorkflowTimeoutPolicy{ + StartToClose: metav1.Duration{Duration: 5 * time.Minute}, + ScheduleToClose: &metav1.Duration{Duration: 30 * time.Minute}, + Heartbeat: &metav1.Duration{Duration: 1 * time.Minute}, + }, + }, + check: func(t *testing.T, opts workflow.ActivityOptions) { + require.Equal(t, 5*time.Minute, opts.StartToCloseTimeout) + require.Equal(t, 30*time.Minute, opts.ScheduleToCloseTimeout) + require.Equal(t, 1*time.Minute, opts.HeartbeatTimeout) + }, + }, + { + name: "non-retryable error types", + policy: &v1alpha2.StepPolicy{ + Retry: &v1alpha2.WorkflowRetryPolicy{ + MaxAttempts: 3, + NonRetryableErrors: []string{"InvalidInput", "AuthFailed"}, + }, + }, + check: func(t *testing.T, opts workflow.ActivityOptions) { + require.NotNil(t, opts.RetryPolicy) + require.Equal(t, []string{"InvalidInput", "AuthFailed"}, opts.RetryPolicy.NonRetryableErrorTypes) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := buildActivityOptions(tt.policy) + tt.check(t, opts) + }) + } +} diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 250b32492..cc5226759 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -37,6 +37,7 @@ import ( "k8s.io/apimachinery/pkg/types" "github.com/kagent-dev/kagent/go/core/internal/a2a" + "github.com/kagent-dev/kagent/go/core/internal/compiler" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/mcp" versionmetrics "github.com/kagent-dev/kagent/go/core/internal/metrics" @@ -129,6 +130,7 @@ type Config struct { UrlFile string VectorEnabled bool } + GitRepoMCPURL string } func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { @@ -168,6 +170,7 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.DurationVar(&cfg.Streaming.Timeout, "streaming-timeout", 600*time.Second, "The timeout for the streaming connection.") commandLine.StringVar(&cfg.Proxy.URL, "proxy-url", "", "Proxy URL for internally-built k8s URLs (e.g., http://proxy.kagent.svc.cluster.local:8080)") + commandLine.StringVar(&cfg.GitRepoMCPURL, "gitrepo-mcp-url", "", "URL of the gitrepo-mcp service (e.g., http://gitrepo-mcp.kagent.svc.cluster.local:8080)") commandLine.StringVar(&agent_translator.DefaultImageConfig.Registry, "image-registry", agent_translator.DefaultImageConfig.Registry, "The registry to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Tag, "image-tag", agent_translator.DefaultImageConfig.Tag, "The tag to use for the image.") @@ -452,6 +455,53 @@ func Start(getExtensionConfig GetExtensionConfig) { os.Exit(1) } + if err := (&controller.AgentCronJobController{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + A2ABaseURL: cfg.A2ABaseUrl, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AgentCronJob") + os.Exit(1) + } + + dagCompiler := compiler.NewDAGCompiler() + + if err := (&controller.WorkflowTemplateController{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Compiler: dagCompiler, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "WorkflowTemplate") + os.Exit(1) + } + + if err := (&controller.WorkflowRunController{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Compiler: dagCompiler, + // TemporalClient will be injected when Temporal integration is enabled. + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "WorkflowRun") + os.Exit(1) + } + + // Status syncer runs as a background goroutine, polling Temporal for workflow status updates. + if err := mgr.Add(&controller.WorkflowRunStatusSyncer{ + K8sClient: mgr.GetClient(), + // TemporalClient will be injected when Temporal integration is enabled. + }); err != nil { + setupLog.Error(err, "unable to add status syncer") + os.Exit(1) + } + + // Retention controller periodically cleans up old WorkflowRuns based on history limits and TTL. + if err := mgr.Add(&controller.WorkflowRunRetentionController{ + K8sClient: mgr.GetClient(), + }); err != nil { + setupLog.Error(err, "unable to add retention controller") + os.Exit(1) + } + if err := reconcilerutils.SetupOwnerIndexes(mgr, rcnclr.GetOwnedResourceTypes()); err != nil { setupLog.Error(err, "failed to setup indexes for owned resources") os.Exit(1) @@ -528,6 +578,7 @@ func Start(getExtensionConfig GetExtensionConfig) { Authenticator: extensionCfg.Authenticator, ProxyURL: cfg.Proxy.URL, Reconciler: rcnclr, + GitRepoMCPURL: cfg.GitRepoMCPURL, }) if err != nil { setupLog.Error(err, "unable to create HTTP server") diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index 9d6786aed..f1a14bdcb 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -59,4 +59,18 @@ var ( "Well-known endpoint for the Security Token Service (STS) used for token exchange.", ComponentAgentRuntime, ) + + TemporalHostAddr = RegisterStringVar( + "TEMPORAL_HOST_ADDR", + "temporal-server:7233", + "Temporal server gRPC address for workflow execution.", + ComponentAgentRuntime, + ) + + NATSAddr = RegisterStringVar( + "NATS_ADDR", + "nats://nats:4222", + "NATS server address for real-time streaming.", + ComponentAgentRuntime, + ) ) diff --git a/go/core/test/e2e/cli_invoke_test.go b/go/core/test/e2e/cli_invoke_test.go new file mode 100644 index 000000000..51a956dec --- /dev/null +++ b/go/core/test/e2e/cli_invoke_test.go @@ -0,0 +1,173 @@ +package e2e_test + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" + "trpc.group/trpc-go/trpc-a2a-go/protocol" +) + +// skipIfNoCLITest skips CLI E2E tests unless CLI_TEST=1 is set. +// These tests require a running kagent cluster with deployed agents. +func skipIfNoCLITest(t *testing.T) { + t.Helper() + if os.Getenv("CLI_TEST") == "" { + t.Skip("Skipping CLI E2E test: set CLI_TEST=1 to run (requires kagent cluster with agents)") + } +} + +// a2aURLForAgent returns the A2A endpoint URL for a given agent. +func a2aURLForAgent(agentName string) string { + base := kagentBaseURL() + return base + "/api/a2a/kagent/" + agentName + "/" +} + +// invokeAgent sends a message to an agent via A2A and returns the response text. +func invokeAgent(t *testing.T, agentName, message string) string { + t.Helper() + + url := a2aURLForAgent(agentName) + t.Logf("Invoking agent %s at %s", agentName, url) + + client, err := a2aclient.NewA2AClient(url, a2aclient.WithTimeout(5*time.Minute)) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + result, err := client.SendMessage(ctx, protocol.SendMessageParams{ + Message: protocol.Message{ + Kind: protocol.KindMessage, + Role: protocol.MessageRoleUser, + Parts: []protocol.Part{protocol.NewTextPart(message)}, + }, + }) + require.NoError(t, err, "SendMessage failed for agent %s", agentName) + + task, ok := result.Result.(*protocol.Task) + require.True(t, ok, "expected Task result, got %T", result.Result) + + // Extract text from history (agent messages). + var texts []string + for _, msg := range task.History { + if msg.Role == protocol.MessageRoleAgent { + for _, part := range msg.Parts { + if tp, ok := part.(*protocol.TextPart); ok { + texts = append(texts, tp.Text) + } + } + } + } + + // Also check the status message. + if task.Status.Message != nil { + for _, part := range task.Status.Message.Parts { + if tp, ok := part.(*protocol.TextPart); ok { + texts = append(texts, tp.Text) + } + } + } + + return strings.Join(texts, " ") +} + +// TestE2ECLIInvokeIstioAgentVersion invokes the istio-agent and verifies the +// response contains Istio version information. +func TestE2ECLIInvokeIstioAgentVersion(t *testing.T) { + skipIfNoCLITest(t) + + text := invokeAgent(t, "istio-agent", "What version of Istio is installed in the cluster?") + t.Logf("Response text: %s", text) + + lowerText := strings.ToLower(text) + assert.True(t, + strings.Contains(lowerText, "istio") || strings.Contains(lowerText, "version"), + "Response should mention Istio or version, got: %s", text, + ) +} + +// TestE2ECLIInvokeAgents is a table-driven test for invoking agents via the A2A +// protocol and evaluating responses against expected content. +func TestE2ECLIInvokeAgents(t *testing.T) { + skipIfNoCLITest(t) + + tests := []struct { + name string + agent string + task string + expectContains []string // response should contain at least one (case-insensitive) + }{ + { + name: "istio_version", + agent: "istio-agent", + task: "What version of Istio is installed in the cluster?", + expectContains: []string{"istio", "version", "1."}, + }, + { + name: "istio_namespace", + agent: "istio-agent", + task: "What namespace is Istio installed in?", + expectContains: []string{"istio-system", "istio", "namespace"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + text := invokeAgent(t, tt.agent, tt.task) + t.Logf("Response: %s", text) + + lowerText := strings.ToLower(text) + found := false + for _, expected := range tt.expectContains { + if strings.Contains(lowerText, strings.ToLower(expected)) { + found = true + break + } + } + assert.True(t, found, + "Response should contain one of %v, got: %s", tt.expectContains, text, + ) + }) + } +} + +// TestE2ECLIBinaryInvoke smoke-tests the kagent CLI binary directly. +// Requires the `kagent` binary in PATH (or KAGENT_BIN env var). +func TestE2ECLIBinaryInvoke(t *testing.T) { + skipIfNoCLITest(t) + if os.Getenv("CLI_BINARY_TEST") == "" { + t.Skip("Skipping CLI binary test: set CLI_BINARY_TEST=1 to run (requires kagent binary in PATH)") + } + + bin := os.Getenv("KAGENT_BIN") + if bin == "" { + bin = "kagent" + } + + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("kagent binary not found: %v", err) + } + + args := []string{"invoke", "--agent", "istio-agent", "--task", "What version of Istio is installed?"} + if url := os.Getenv("KAGENT_URL"); url != "" { + args = append(args, "--kagent-url", url) + } + + cmd := exec.CommandContext(t.Context(), bin, args...) + out, err := cmd.CombinedOutput() + t.Logf("kagent invoke output:\n%s", string(out)) + require.NoError(t, err, "kagent invoke failed") + + lowerOut := strings.ToLower(string(out)) + assert.True(t, + strings.Contains(lowerOut, "istio") || strings.Contains(lowerOut, "version"), + "CLI output should mention istio or version", + ) +} diff --git a/go/core/test/e2e/invoke_api_test.go b/go/core/test/e2e/invoke_api_test.go index b8743badc..72b12a6ed 100644 --- a/go/core/test/e2e/invoke_api_test.go +++ b/go/core/test/e2e/invoke_api_test.go @@ -143,6 +143,7 @@ type AgentOptions struct { ImageRepository *string Memory *v1alpha2.MemorySpec PromptTemplate *v1alpha2.PromptTemplateSpec + Tools []*v1alpha2.Tool } // setupAgentWithOptions creates and returns an agent resource with custom options diff --git a/go/core/test/e2e/mocks/invoke_temporal_agent.json b/go/core/test/e2e/mocks/invoke_temporal_agent.json new file mode 100644 index 000000000..cde8a6b2f --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_temporal_agent.json @@ -0,0 +1,35 @@ +{ + "openai": [ + { + "name": "temporal_simple_request", + "match": { + "match_type": "contains", + "message": { + "content": "What is the capital of France?", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-temporal-1", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "content": "The capital of France is Paris.", + "role": "assistant" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 8, + "total_tokens": 23 + } + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_temporal_child.json b/go/core/test/e2e/mocks/invoke_temporal_child.json new file mode 100644 index 000000000..bfa1bca82 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_temporal_child.json @@ -0,0 +1,76 @@ +{ + "openai": [ + { + "name": "temporal_parent_invokes_child", + "match": { + "match_type": "contains", + "message": { + "content": "ask the specialist", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-temporal-child-1", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_a2a_1", + "type": "function", + "function": { + "name": "invoke_agent", + "arguments": "{\"agent\": \"temporal-child-test\", \"message\": \"What is the capital of France?\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 15, + "total_tokens": 35 + } + } + }, + { + "name": "temporal_parent_after_child", + "match": { + "match_type": "contains", + "message": { + "content": "Paris", + "role": "tool" + } + }, + "response": { + "id": "chatcmpl-temporal-child-2", + "object": "chat.completion", + "created": 1677652289, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The specialist says the capital of France is Paris." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 30, + "completion_tokens": 10, + "total_tokens": 40 + } + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_temporal_hitl.json b/go/core/test/e2e/mocks/invoke_temporal_hitl.json new file mode 100644 index 000000000..3134f2e7a --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_temporal_hitl.json @@ -0,0 +1,66 @@ +{ + "openai": [ + { + "name": "temporal_hitl_request", + "match": { + "match_type": "contains", + "message": { + "content": "deploy to production", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-temporal-hitl-1", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I need approval to deploy to production. [APPROVAL_REQUIRED]" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 12, + "total_tokens": 27 + } + } + }, + { + "name": "temporal_hitl_approved", + "match": { + "match_type": "contains", + "message": { + "content": "APPROVED", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-temporal-hitl-2", + "object": "chat.completion", + "created": 1677652289, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Deployment to production completed successfully." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 25, + "completion_tokens": 8, + "total_tokens": 33 + } + } + } + ] +} diff --git a/go/core/test/e2e/mocks/invoke_temporal_with_tools.json b/go/core/test/e2e/mocks/invoke_temporal_with_tools.json new file mode 100644 index 000000000..0fac9b0f4 --- /dev/null +++ b/go/core/test/e2e/mocks/invoke_temporal_with_tools.json @@ -0,0 +1,76 @@ +{ + "openai": [ + { + "name": "temporal_tool_call_request", + "match": { + "match_type": "contains", + "message": { + "content": "What tools do you have?", + "role": "user" + } + }, + "response": { + "id": "chatcmpl-temporal-tools-1", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_echo_1", + "type": "function", + "function": { + "name": "echo", + "arguments": "{\"message\": \"hello from temporal\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 15, + "total_tokens": 35 + } + } + }, + { + "name": "temporal_tool_result_response", + "match": { + "match_type": "contains", + "message": { + "content": "hello from temporal", + "role": "tool" + } + }, + "response": { + "id": "chatcmpl-temporal-tools-2", + "object": "chat.completion", + "created": 1677652289, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I used the echo tool and got: hello from temporal" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 30, + "completion_tokens": 12, + "total_tokens": 42 + } + } + } + ] +} diff --git a/go/core/test/e2e/plugin_routing_test.go b/go/core/test/e2e/plugin_routing_test.go new file mode 100644 index 000000000..868fd4bae --- /dev/null +++ b/go/core/test/e2e/plugin_routing_test.go @@ -0,0 +1,171 @@ +package e2e_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func kagentBaseURL() string { + kagentURL := os.Getenv("KAGENT_URL") + if kagentURL == "" { + kagentURL = "http://localhost:8083" + } + return kagentURL +} + +// TestE2EPluginRouting verifies the full plugin routing pipeline: +// 1. Create RemoteMCPServer with ui section +// 2. Wait for controller to reconcile (poll /api/plugins) +// 3. Verify /api/plugins returns correct metadata +// 4. Delete CRD +// 5. Verify /api/plugins no longer returns the entry +// 6. Verify /_p/{name}/ returns 404 +func TestE2EPluginRouting(t *testing.T) { + cli := setupK8sClient(t, false) + httpClient := &http.Client{Timeout: 10 * time.Second} + baseURL := kagentBaseURL() + + // Create a RemoteMCPServer with UI metadata + rmcps := &v1alpha2.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-plugin-ui-", + Namespace: "kagent", + }, + Spec: v1alpha2.RemoteMCPServerSpec{ + Description: "Test plugin for E2E routing", + Protocol: v1alpha2.RemoteMCPServerProtocolStreamableHttp, + URL: "http://test-plugin-svc.kagent.svc:8080/mcp", + UI: &v1alpha2.PluginUISpec{ + Enabled: true, + PathPrefix: "test-plugin", + DisplayName: "Test Plugin", + Icon: "puzzle", + Section: "PLUGINS", + }, + }, + } + + err := cli.Create(t.Context(), rmcps) + require.NoError(t, err, "failed to create RemoteMCPServer with UI") + t.Logf("Created RemoteMCPServer %s", rmcps.Name) + cleanup(t, cli, rmcps) + + // Poll /api/plugins until the plugin appears + pluginsURL := baseURL + "/api/plugins" + t.Logf("Polling %s for plugin to appear", pluginsURL) + + var foundPlugin *handlers.PluginResponse + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pluginsURL, nil) + if err != nil { + return false, err + } + resp, err := httpClient.Do(req) + if err != nil { + t.Logf("Request to %s failed: %v", pluginsURL, err) + return false, nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Logf("GET %s returned %d", pluginsURL, resp.StatusCode) + return false, nil + } + + var body api.StandardResponse[[]handlers.PluginResponse] + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Logf("Failed to decode response: %v", err) + return false, nil + } + + for i, p := range body.Data { + if p.PathPrefix == "test-plugin" { + foundPlugin = &body.Data[i] + return true, nil + } + } + + t.Logf("Plugin not yet in /api/plugins (got %d plugins)", len(body.Data)) + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for plugin to appear in /api/plugins") + + // Verify plugin metadata + require.NotNil(t, foundPlugin) + assert.Equal(t, "test-plugin", foundPlugin.PathPrefix) + assert.Equal(t, "Test Plugin", foundPlugin.DisplayName) + assert.Equal(t, "puzzle", foundPlugin.Icon) + assert.Equal(t, "PLUGINS", foundPlugin.Section) + t.Logf("Plugin metadata verified: %+v", foundPlugin) + + // Verify /_p/test-plugin/ returns a response (proxy is set up) + // The upstream doesn't exist, so we expect a 502 (Bad Gateway) rather than 404 + proxyURL := baseURL + "/_p/test-plugin/" + proxyReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, proxyURL, nil) + require.NoError(t, err) + proxyResp, err := httpClient.Do(proxyReq) + require.NoError(t, err) + proxyResp.Body.Close() + // Should NOT be 404 (that would mean plugin routing isn't set up) + assert.NotEqual(t, http.StatusNotFound, proxyResp.StatusCode, + "expected proxy to be configured (got 404, meaning plugin not found in DB)") + t.Logf("Proxy endpoint %s returned %d (expected non-404)", proxyURL, proxyResp.StatusCode) + + // Delete the CRD + t.Logf("Deleting RemoteMCPServer %s", rmcps.Name) + err = cli.Delete(t.Context(), rmcps) + require.NoError(t, err) + + // Poll until plugin disappears from /api/plugins + t.Logf("Waiting for plugin to disappear from /api/plugins") + disappearErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pluginsURL, nil) + if err != nil { + return false, err + } + resp, err := httpClient.Do(req) + if err != nil { + return false, nil + } + defer resp.Body.Close() + + var body api.StandardResponse[[]handlers.PluginResponse] + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return false, nil + } + + for _, p := range body.Data { + if p.PathPrefix == "test-plugin" { + t.Logf("Plugin still present in /api/plugins") + return false, nil + } + } + return true, nil + }) + require.NoError(t, disappearErr, "timed out waiting for plugin to disappear from /api/plugins") + t.Logf("Plugin removed from /api/plugins after CRD deletion") + + // Verify /_p/test-plugin/ returns 404 after deletion + proxyReq2, err := http.NewRequestWithContext(t.Context(), http.MethodGet, proxyURL, nil) + require.NoError(t, err) + proxyResp2, err := httpClient.Do(proxyReq2) + require.NoError(t, err) + proxyResp2.Body.Close() + assert.Equal(t, http.StatusNotFound, proxyResp2.StatusCode, + fmt.Sprintf("expected 404 after plugin deletion, got %d", proxyResp2.StatusCode)) + t.Logf("Proxy endpoint returns 404 after deletion - verified") +} diff --git a/go/core/test/e2e/temporal_test.go b/go/core/test/e2e/temporal_test.go new file mode 100644 index 000000000..13d28a3db --- /dev/null +++ b/go/core/test/e2e/temporal_test.go @@ -0,0 +1,686 @@ +package e2e_test + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8s_runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" + "trpc.group/trpc-go/trpc-a2a-go/protocol" +) + +// skipIfNoTemporal skips the test if the Temporal server is not deployed. +func skipIfNoTemporal(t *testing.T) { + t.Helper() + if os.Getenv("TEMPORAL_ENABLED") == "" { + t.Skip("Skipping Temporal E2E test: set TEMPORAL_ENABLED=1 to run (requires Temporal + NATS in cluster)") + } +} + +// setupK8sClientWithAppsV1 creates a Kubernetes client that includes the appsv1 scheme, +// needed for querying Deployments (e.g. temporal-server, nats). +func setupK8sClientWithAppsV1(t *testing.T) client.Client { + t.Helper() + cfg, err := config.GetConfig() + require.NoError(t, err) + + scheme := k8s_runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + cli, err := client.New(cfg, client.Options{Scheme: scheme}) + require.NoError(t, err) + return cli +} + +// waitForTemporalReady polls the Temporal server service until it is reachable. +func waitForTemporalReady(t *testing.T) { + t.Helper() + t.Log("Waiting for Temporal server to be ready") + cli := setupK8sClientWithAppsV1(t) + + pollErr := wait.PollUntilContextTimeout(t.Context(), 3*time.Second, 120*time.Second, true, func(ctx context.Context) (bool, error) { + var deploy appsv1.Deployment + err := cli.Get(ctx, client.ObjectKey{Namespace: "kagent", Name: "temporal-server"}, &deploy) + if err != nil { + t.Logf("Temporal server deployment not found: %v", err) + return false, nil + } + if deploy.Status.ReadyReplicas > 0 { + return true, nil + } + t.Logf("Temporal server not ready yet (readyReplicas=%d)", deploy.Status.ReadyReplicas) + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for Temporal server") +} + +// waitForNATSReady polls the NATS service until it is reachable. +func waitForNATSReady(t *testing.T) { + t.Helper() + t.Log("Waiting for NATS to be ready") + cli := setupK8sClientWithAppsV1(t) + + pollErr := wait.PollUntilContextTimeout(t.Context(), 3*time.Second, 120*time.Second, true, func(ctx context.Context) (bool, error) { + var deploy appsv1.Deployment + err := cli.Get(ctx, client.ObjectKey{Namespace: "kagent", Name: "nats"}, &deploy) + if err != nil { + t.Logf("NATS deployment not found: %v", err) + return false, nil + } + if deploy.Status.ReadyReplicas > 0 { + return true, nil + } + t.Logf("NATS not ready yet (readyReplicas=%d)", deploy.Status.ReadyReplicas) + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for NATS") +} + +// setupTemporalAgent creates an agent with temporal.enabled: true using the Go ADK image. +func setupTemporalAgent(t *testing.T, cli client.Client, modelConfigName string, opts AgentOptions) *v1alpha2.Agent { + if opts.Name == "" { + opts.Name = "temporal-test" + } + if opts.SystemMessage == "" { + opts.SystemMessage = "You are a test agent." + } + + golangADKRepo := "kagent-dev/kagent/golang-adk" + opts.ImageRepository = &golangADKRepo + + agent := generateAgent(modelConfigName, opts.Tools, opts) + agent.Spec.Temporal = &v1alpha2.TemporalSpec{ + Enabled: true, + } + + err := cli.Create(t.Context(), agent) + require.NoError(t, err) + cleanup(t, cli, agent) + + // Wait for agent to be ready. + args := []string{ + "wait", "--for", "condition=Ready", "--timeout=2m", + "agents.kagent.dev", agent.Name, "-n", "kagent", + } + cmd := exec.CommandContext(t.Context(), "kubectl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + require.NoError(t, cmd.Run()) + + waitForEndpoint(t, agent.Namespace, agent.Name) + + return agent +} + +// TestE2ETemporalInfrastructure verifies that Temporal server and NATS are +// deployed and healthy when temporal.enabled=true in Helm values. +func TestE2ETemporalInfrastructure(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + // Verify Temporal server service exists. + var svc corev1.Service + err := cli.Get(t.Context(), client.ObjectKey{Namespace: "kagent", Name: "temporal-server"}, &svc) + require.NoError(t, err, "Temporal server service should exist") + assert.Equal(t, int32(7233), svc.Spec.Ports[0].Port, "Temporal server should listen on port 7233") + + // Verify NATS service exists. + err = cli.Get(t.Context(), client.ObjectKey{Namespace: "kagent", Name: "nats"}, &svc) + require.NoError(t, err, "NATS service should exist") + assert.Equal(t, int32(4222), svc.Spec.Ports[0].Port, "NATS should listen on port 4222") + + t.Log("Temporal and NATS infrastructure verified") +} + +// TestE2ETemporalAgentCRDTranslation verifies that an Agent CRD with +// temporal.enabled: true produces a pod with the correct env vars and config. +func TestE2ETemporalAgentCRDTranslation(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + // Setup mock server. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "temporal-crd-test", + }) + + // Verify the agent pod has TEMPORAL_HOST_ADDR and NATS_ADDR env vars. + podList := &corev1.PodList{} + err := cli.List(t.Context(), podList, + client.InNamespace("kagent"), + client.MatchingLabels{ + "app.kubernetes.io/name": agent.Name, + "app.kubernetes.io/managed-by": "kagent", + }, + ) + require.NoError(t, err) + require.NotEmpty(t, podList.Items, "Agent should have at least one pod") + + pod := podList.Items[0] + var hasTemporalAddr, hasNATSAddr bool + for _, container := range pod.Spec.Containers { + for _, env := range container.Env { + switch env.Name { + case "TEMPORAL_HOST_ADDR": + hasTemporalAddr = true + t.Logf("TEMPORAL_HOST_ADDR=%s", env.Value) + case "NATS_ADDR": + hasNATSAddr = true + t.Logf("NATS_ADDR=%s", env.Value) + } + } + } + assert.True(t, hasTemporalAddr, "Pod should have TEMPORAL_HOST_ADDR env var") + assert.True(t, hasNATSAddr, "Pod should have NATS_ADDR env var") + + // Verify agent CRD has temporal spec reflected. + var updatedAgent v1alpha2.Agent + err = cli.Get(t.Context(), client.ObjectKeyFromObject(agent), &updatedAgent) + require.NoError(t, err) + require.NotNil(t, updatedAgent.Spec.Temporal, "Agent should have Temporal spec") + assert.True(t, updatedAgent.Spec.Temporal.Enabled, "Temporal should be enabled") +} + +// TestE2ETemporalWorkflowExecution creates an Agent CRD with temporal.enabled: true, +// sends an A2A message, and verifies the workflow executes and returns a response. +func TestE2ETemporalWorkflowExecution(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + // Setup mock server. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "temporal-exec-test", + }) + + // Setup A2A client. + a2aClient := setupA2AClient(t, agent) + + t.Run("sync_invocation", func(t *testing.T) { + runSyncTest(t, a2aClient, "What is the capital of France?", "Paris", nil) + }) + + t.Run("streaming_invocation", func(t *testing.T) { + runStreamingTest(t, a2aClient, "What is the capital of France?", "Paris") + }) +} + +// TestE2ETemporalUIPlugin verifies the Temporal UI plugin is accessible +// via the kagent plugin proxy when temporal.enabled=true. +func TestE2ETemporalUIPlugin(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + + baseURL := kagentBaseURL() + httpClient := &http.Client{Timeout: 10 * time.Second} + + // Poll /api/plugins for the temporal plugin. + pluginsURL := baseURL + "/api/plugins" + t.Logf("Checking %s for temporal plugin", pluginsURL) + + var found bool + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pluginsURL, nil) + if err != nil { + return false, err + } + resp, err := httpClient.Do(req) + if err != nil { + return false, nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, nil + } + + // Just check that the proxy route exists for temporal (Go serves proxy at /_p/, not /plugins/). + proxyURL := baseURL + "/_p/temporal/" + proxyReq, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyURL, nil) + if err != nil { + return false, nil + } + proxyResp, err := httpClient.Do(proxyReq) + if err != nil { + return false, nil + } + proxyResp.Body.Close() + + // 502 means proxy is configured but upstream may not be ready yet. + // 404 means the route doesn't exist at all. + if proxyResp.StatusCode != http.StatusNotFound { + found = true + return true, nil + } + return false, nil + }) + + if pollErr != nil { + t.Logf("Temporal UI plugin not found (may not be configured as RemoteMCPServer): %v", pollErr) + t.Skip("Temporal UI plugin not configured") + } + + assert.True(t, found, "Temporal UI should be accessible via plugin proxy") + t.Log("Temporal UI plugin verified") +} + +// TestE2ETemporalFallbackPath verifies that an agent WITHOUT temporal.enabled +// still works via the synchronous execution path (unchanged behavior). +func TestE2ETemporalFallbackPath(t *testing.T) { + skipIfNoTemporal(t) + + // Setup mock server. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_golang_adk_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + + // Create agent WITHOUT temporal spec (fallback to sync path). + golangADKRepo := "kagent-dev/kagent/golang-adk" + agent := setupAgentWithOptions(t, cli, modelCfg.Name, nil, AgentOptions{ + Name: "temporal-fallback-test", + ImageRepository: &golangADKRepo, + }) + + a2aClient := setupA2AClient(t, agent) + + t.Run("sync_invocation_no_temporal", func(t *testing.T) { + runSyncTest(t, a2aClient, "What is 2+2?", "4", nil) + }) +} + +// TestE2ETemporalCrashRecovery verifies that a Temporal workflow resumes +// after an agent pod restart. It kills the pod mid-execution and checks +// that the workflow eventually completes. +func TestE2ETemporalCrashRecovery(t *testing.T) { + skipIfNoTemporal(t) + if os.Getenv("TEMPORAL_CRASH_RECOVERY_TEST") == "" { + t.Skip("Skipping crash recovery test: set TEMPORAL_CRASH_RECOVERY_TEST=1 to run (slow, destructive)") + } + waitForTemporalReady(t) + waitForNATSReady(t) + + // Setup mock server. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "temporal-crash-test", + }) + + // Delete the agent pod to simulate a crash. + podList := &corev1.PodList{} + err := cli.List(t.Context(), podList, + client.InNamespace("kagent"), + client.MatchingLabels{ + "app.kubernetes.io/name": agent.Name, + "app.kubernetes.io/managed-by": "kagent", + }, + ) + require.NoError(t, err) + require.NotEmpty(t, podList.Items) + + // Delete the pod. + t.Logf("Deleting pod %s to simulate crash", podList.Items[0].Name) + err = cli.Delete(t.Context(), &podList.Items[0]) + require.NoError(t, err) + + // Wait for replacement pod to come up. + t.Log("Waiting for replacement pod") + pollErr := wait.PollUntilContextTimeout(t.Context(), 3*time.Second, 120*time.Second, true, func(ctx context.Context) (bool, error) { + var pods corev1.PodList + if err := cli.List(ctx, &pods, + client.InNamespace("kagent"), + client.MatchingLabels{ + "app.kubernetes.io/name": agent.Name, + "app.kubernetes.io/managed-by": "kagent", + }, + ); err != nil { + return false, nil + } + for _, pod := range pods.Items { + if pod.Status.Phase == corev1.PodRunning && pod.Name != podList.Items[0].Name { + return true, nil + } + } + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for replacement pod") + + waitForEndpoint(t, agent.Namespace, agent.Name) + + // After recovery, the agent should still be able to handle requests. + a2aClient := setupA2AClient(t, agent) + t.Run("post_crash_invocation", func(t *testing.T) { + runSyncTest(t, a2aClient, "What is the capital of France?", "Paris", nil) + }) +} + +// TestE2ETemporalWithCustomTimeout verifies that an agent with a custom +// workflow timeout in the TemporalSpec is correctly configured. +func TestE2ETemporalWithCustomTimeout(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + + golangADKRepo := "kagent-dev/kagent/golang-adk" + agent := generateAgent(modelCfg.Name, nil, AgentOptions{ + Name: "temporal-timeout-test", + ImageRepository: &golangADKRepo, + }) + agent.Spec.Temporal = &v1alpha2.TemporalSpec{ + Enabled: true, + WorkflowTimeout: &metav1.Duration{Duration: 1 * time.Hour}, + RetryPolicy: &v1alpha2.TemporalRetryPolicy{ + LLMMaxAttempts: int32Ptr(3), + ToolMaxAttempts: int32Ptr(2), + }, + } + + err := cli.Create(t.Context(), agent) + require.NoError(t, err) + cleanup(t, cli, agent) + + // Wait for agent to be ready. + args := []string{ + "wait", "--for", "condition=Ready", "--timeout=2m", + "agents.kagent.dev", agent.Name, "-n", "kagent", + } + cmd := exec.CommandContext(t.Context(), "kubectl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + require.NoError(t, cmd.Run()) + + waitForEndpoint(t, agent.Namespace, agent.Name) + + // Verify agent is responsive with custom config. + a2aClient := setupA2AClient(t, agent) + runSyncTest(t, a2aClient, "What is the capital of France?", "Paris", nil) + + // Verify the CRD persisted the custom timeout. + var updatedAgent v1alpha2.Agent + err = cli.Get(t.Context(), client.ObjectKeyFromObject(agent), &updatedAgent) + require.NoError(t, err) + require.NotNil(t, updatedAgent.Spec.Temporal) + require.NotNil(t, updatedAgent.Spec.Temporal.WorkflowTimeout) + assert.Equal(t, 1*time.Hour, updatedAgent.Spec.Temporal.WorkflowTimeout.Duration) + require.NotNil(t, updatedAgent.Spec.Temporal.RetryPolicy) + assert.Equal(t, int32(3), *updatedAgent.Spec.Temporal.RetryPolicy.LLMMaxAttempts) + assert.Equal(t, int32(2), *updatedAgent.Spec.Temporal.RetryPolicy.ToolMaxAttempts) +} + +func int32Ptr(v int32) *int32 { + return &v +} + +// TestE2ETemporalToolExecution verifies multi-turn Temporal workflow with real +// MCP tool execution. The mock LLM returns a tool call, the agent executes the +// tool via MCP, and the workflow loops back to the LLM with the result. +func TestE2ETemporalToolExecution(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + // Setup mock server with multi-turn tool call responses. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_with_tools.json") + defer stopServer() + + // Setup Kubernetes client (include v1alpha1 for MCPServer). + cli := setupK8sClient(t, true) + mcpServer := setupMCPServer(t, cli) + modelCfg := setupModelConfig(t, cli, baseURL) + + // Define tools referencing the everything MCP server's echo tool. + tools := []*v1alpha2.Tool{ + { + Type: v1alpha2.ToolProviderType_McpServer, + McpServer: &v1alpha2.McpServerTool{ + TypedReference: v1alpha2.TypedReference{ + ApiGroup: "kagent.dev", + Kind: "MCPServer", + Name: mcpServer.Name, + }, + ToolNames: []string{"echo"}, + }, + }, + } + + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "temporal-tool-test", + Tools: tools, + }) + + a2aClient := setupA2AClient(t, agent) + + t.Run("tool_call_workflow", func(t *testing.T) { + runSyncTest(t, a2aClient, "What tools do you have?", "echo", nil) + }) + + t.Run("tool_call_streaming", func(t *testing.T) { + runStreamingTest(t, a2aClient, "What tools do you have?", "echo") + }) +} + +// TestE2ETemporalChildWorkflow verifies multi-agent orchestration where a parent +// agent invokes a child agent via Temporal child workflow. The parent's mock LLM +// returns an invoke_agent tool call, triggering a child workflow on the child +// agent's task queue. The child workflow executes and returns a result to the parent. +func TestE2ETemporalChildWorkflow(t *testing.T) { + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + // Two mock servers: parent returns invoke_agent tool call, child returns simple response. + parentURL, stopParent := setupMockServer(t, "mocks/invoke_temporal_child.json") + defer stopParent() + childURL, stopChild := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopChild() + + cli := setupK8sClient(t, false) + parentModelCfg := setupModelConfig(t, cli, parentURL) + childModelCfg := setupModelConfig(t, cli, childURL) + + // Create child agent first (must be ready before parent invokes it). + childAgent := setupTemporalAgent(t, cli, childModelCfg.Name, AgentOptions{ + Name: "temporal-child-test", + }) + _ = childAgent // ensure child is deployed and ready + + // Create parent agent. + parentAgent := setupTemporalAgent(t, cli, parentModelCfg.Name, AgentOptions{ + Name: "temporal-parent-test", + }) + + a2aClient := setupA2AClient(t, parentAgent) + + t.Run("parent_invokes_child", func(t *testing.T) { + runSyncTest(t, a2aClient, "ask the specialist", "Paris", nil) + }) +} + +// TestE2ETemporalHITLApproval verifies the HITL (Human-In-The-Loop) signal flow. +// The workflow pauses waiting for an approval signal; the test sends the signal +// via the Temporal Go SDK client and verifies the workflow resumes and completes. +// +// This test requires Temporal to be deployed and accessible from the test process. +// It is gated by TEMPORAL_HITL_TEST=1 because it depends on the LLM activity +// recognizing the NeedsApproval pattern, which requires implementation alignment. +func TestE2ETemporalHITLApproval(t *testing.T) { + skipIfNoTemporal(t) + if os.Getenv("TEMPORAL_HITL_TEST") == "" { + t.Skip("Skipping HITL approval test: set TEMPORAL_HITL_TEST=1 to run (requires HITL workflow detection support)") + } + waitForTemporalReady(t) + waitForNATSReady(t) + + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_hitl.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "temporal-hitl-test", + }) + + a2aClient := setupA2AClient(t, agent) + + // Send the message that triggers HITL approval in a background goroutine. + // The workflow will block waiting for the approval signal. + type asyncResult struct { + task *protocol.Task + err error + } + resultCh := make(chan asyncResult, 1) + + go func() { + task := runSyncTestNoFatal(t, a2aClient, "deploy to production", "completed") + resultCh <- asyncResult{task: task} + }() + + // Give the workflow time to start and reach the approval signal wait. + time.Sleep(5 * time.Second) + + // Send the approval signal via kubectl exec into the Temporal server pod. + // The workflow is waiting on signal channel "approval" with an ApprovalDecision payload. + taskQueue := fmt.Sprintf("agent-%s", agent.Name) + workflowID := fmt.Sprintf("temporal-hitl-test/%s", taskQueue) // approximate; may vary + t.Logf("Attempting to signal workflow on task queue %s", taskQueue) + + // Use tctl to signal the workflow (if available). + signalPayload := `{"approved":true,"reason":"test approval"}` + cmd := exec.CommandContext(t.Context(), "kubectl", "exec", + "deploy/temporal-server", "-n", "kagent", "--", + "tctl", "workflow", "signal", + "--workflow_id", workflowID, + "--name", "approval", + "--input", signalPayload, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("tctl signal output: %s", string(output)) + t.Skipf("Could not send approval signal via tctl: %v", err) + } + + // Wait for the async result. + select { + case result := <-resultCh: + if result.err != nil { + t.Fatalf("HITL workflow failed: %v", result.err) + } + t.Logf("HITL workflow completed successfully") + case <-time.After(60 * time.Second): + t.Fatal("timed out waiting for HITL workflow to complete after approval") + } +} + +// runSyncTestNoFatal is like runSyncTest but returns the task instead of calling t.Fatal. +// Used for async test patterns where we need to handle errors in goroutines. +func runSyncTestNoFatal(t *testing.T, a2aClient *a2aclient.A2AClient, userMessage, expectedText string) *protocol.Task { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + msg := protocol.Message{ + Kind: protocol.KindMessage, + Role: protocol.MessageRoleUser, + Parts: []protocol.Part{protocol.NewTextPart(userMessage)}, + } + + result, err := a2aClient.SendMessage(ctx, protocol.SendMessageParams{Message: msg}) + if err != nil { + t.Logf("SendMessage error: %v", err) + return nil + } + + taskResult, ok := result.Result.(*protocol.Task) + if !ok { + t.Logf("unexpected result type: %T", result.Result) + return nil + } + + return taskResult +} + +// TestE2ETemporalWorkflowVisibleInTemporalUI verifies that after executing +// an agent workflow, the workflow execution can be queried via kubectl port-forward +// to the Temporal server (gRPC). This validates end-to-end that workflows are +// actually registered in Temporal. +func TestE2ETemporalWorkflowVisibleInTemporalUI(t *testing.T) { + skipIfNoTemporal(t) + if os.Getenv("TEMPORAL_UI_TEST") == "" { + t.Skip("Skipping Temporal UI test: set TEMPORAL_UI_TEST=1 to run") + } + waitForTemporalReady(t) + waitForNATSReady(t) + + // This test uses kubectl + tctl to verify workflow existence. + // The actual check is: after sending a message, verify that the Temporal + // server has a workflow execution for the agent's task queue. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "temporal-ui-test", + }) + + a2aClient := setupA2AClient(t, agent) + runSyncTest(t, a2aClient, "What is the capital of France?", "Paris", nil) + + // Use kubectl exec to run tctl inside the Temporal server pod to verify workflow. + taskQueue := fmt.Sprintf("agent-%s", agent.Name) + t.Logf("Verifying workflow on task queue: %s", taskQueue) + + // List workflow executions using kubectl exec into the temporal-server pod. + cmd := exec.CommandContext(t.Context(), "kubectl", "exec", + "deploy/temporal-server", "-n", "kagent", "--", + "tctl", "workflow", "list", "--query", + fmt.Sprintf("TaskQueue='%s'", taskQueue), + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("tctl output: %s", string(output)) + t.Logf("tctl command failed (tctl may not be available): %v", err) + t.Skip("tctl not available in Temporal server pod") + } + t.Logf("Workflow list for task queue %s:\n%s", taskQueue, string(output)) +} diff --git a/go/core/test/e2e/workflow_test.go b/go/core/test/e2e/workflow_test.go new file mode 100644 index 000000000..e05461fae --- /dev/null +++ b/go/core/test/e2e/workflow_test.go @@ -0,0 +1,679 @@ +package e2e_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// skipIfNoWorkflows skips the test if the workflow E2E tests are not enabled. +func skipIfNoWorkflows(t *testing.T) { + t.Helper() + if os.Getenv("WORKFLOW_E2E_ENABLED") == "" { + t.Skip("Skipping workflow E2E test: set WORKFLOW_E2E_ENABLED=1 to run (requires Temporal + NATS in cluster)") + } +} + +// createWorkflowTemplate creates a WorkflowTemplate and registers cleanup. +func createWorkflowTemplate(t *testing.T, cli client.Client, tmpl *v1alpha2.WorkflowTemplate) *v1alpha2.WorkflowTemplate { + t.Helper() + err := cli.Create(t.Context(), tmpl) + require.NoError(t, err, "failed to create WorkflowTemplate") + t.Cleanup(func() { + if os.Getenv("SKIP_CLEANUP") == "" || !t.Failed() { + cli.Delete(context.Background(), tmpl) //nolint:errcheck + } + }) + return tmpl +} + +// createWorkflowRun creates a WorkflowRun and registers cleanup. +func createWorkflowRun(t *testing.T, cli client.Client, run *v1alpha2.WorkflowRun) *v1alpha2.WorkflowRun { + t.Helper() + err := cli.Create(t.Context(), run) + require.NoError(t, err, "failed to create WorkflowRun") + t.Cleanup(func() { + if os.Getenv("SKIP_CLEANUP") == "" || !t.Failed() { + cli.Delete(context.Background(), run) //nolint:errcheck + } + }) + return run +} + +// waitForTemplateValidated polls until a WorkflowTemplate has Accepted=True. +func waitForTemplateValidated(t *testing.T, cli client.Client, key client.ObjectKey) *v1alpha2.WorkflowTemplate { + t.Helper() + var tmpl v1alpha2.WorkflowTemplate + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + if err := cli.Get(ctx, key, &tmpl); err != nil { + return false, nil + } + for _, c := range tmpl.Status.Conditions { + if c.Type == v1alpha2.WorkflowTemplateConditionAccepted { + return c.Status == metav1.ConditionTrue, nil + } + } + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for WorkflowTemplate %s to be validated", key.Name) + return &tmpl +} + +// waitForTemplateRejected polls until a WorkflowTemplate has Accepted=False with expected reason. +func waitForTemplateRejected(t *testing.T, cli client.Client, key client.ObjectKey, expectedReason string) *v1alpha2.WorkflowTemplate { + t.Helper() + var tmpl v1alpha2.WorkflowTemplate + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + if err := cli.Get(ctx, key, &tmpl); err != nil { + return false, nil + } + for _, c := range tmpl.Status.Conditions { + if c.Type == v1alpha2.WorkflowTemplateConditionAccepted && c.Status == metav1.ConditionFalse { + return true, nil + } + } + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for WorkflowTemplate %s to be rejected", key.Name) + + for _, c := range tmpl.Status.Conditions { + if c.Type == v1alpha2.WorkflowTemplateConditionAccepted { + assert.Equal(t, expectedReason, c.Reason) + } + } + return &tmpl +} + +// waitForRunPhase polls until a WorkflowRun reaches the expected phase. +func waitForRunPhase(t *testing.T, cli client.Client, key client.ObjectKey, phase string, timeout time.Duration) *v1alpha2.WorkflowRun { + t.Helper() + var run v1alpha2.WorkflowRun + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + if err := cli.Get(ctx, key, &run); err != nil { + return false, nil + } + return run.Status.Phase == phase, nil + }) + require.NoError(t, pollErr, "timed out waiting for WorkflowRun %s to reach phase %s (current: %s)", key.Name, phase, run.Status.Phase) + return &run +} + +// TestE2EWorkflowSequential verifies that a linear A->B->C workflow executes +// steps sequentially and reaches Succeeded phase. +func TestE2EWorkflowSequential(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + // Create a template with 3 sequential steps. + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-seq-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Sequential A->B->C test", + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", With: map[string]string{"msg": "hello"}}, + {Name: "step-b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-a"}, With: map[string]string{"msg": "world"}}, + {Name: "step-c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-b"}, With: map[string]string{"msg": "done"}}, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + // Create a run. + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-seq-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + + // Wait for run to succeed. + finalRun := waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseSucceeded), 120*time.Second) + + // Verify step statuses. + require.Len(t, finalRun.Status.Steps, 3) + for _, step := range finalRun.Status.Steps { + assert.Equal(t, string(v1alpha2.StepPhaseSucceeded), string(step.Phase), "step %s should be Succeeded", step.Name) + } + assert.NotNil(t, finalRun.Status.CompletionTime, "completionTime should be set") +} + +// TestE2EWorkflowParallelDAG verifies that A->[B,C]->D executes B and C +// concurrently after A, and D after both. +func TestE2EWorkflowParallelDAG(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-parallel-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Parallel DAG A->[B,C]->D test", + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", With: map[string]string{"val": "root"}}, + {Name: "step-b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-a"}, With: map[string]string{"val": "left"}}, + {Name: "step-c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-a"}, With: map[string]string{"val": "right"}}, + {Name: "step-d", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-b", "step-c"}, With: map[string]string{"val": "join"}}, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-parallel-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + + finalRun := waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseSucceeded), 120*time.Second) + require.Len(t, finalRun.Status.Steps, 4) + for _, step := range finalRun.Status.Steps { + assert.Equal(t, string(v1alpha2.StepPhaseSucceeded), string(step.Phase), "step %s should be Succeeded", step.Name) + } +} + +// TestE2EWorkflowAgentStep verifies that a workflow with an agent step invokes +// a child workflow on the agent's task queue and maps the output. +func TestE2EWorkflowAgentStep(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + // Setup mock LLM for the agent. + baseURL, stopServer := setupMockServer(t, "mocks/invoke_temporal_agent.json") + defer stopServer() + + cli := setupK8sClient(t, false) + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupTemporalAgent(t, cli, modelCfg.Name, AgentOptions{ + Name: "wf-agent-step-test", + }) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-agent-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Agent step test", + Steps: []v1alpha2.StepSpec{ + { + Name: "ask-agent", + Type: v1alpha2.StepTypeAgent, + AgentRef: agent.Name, + Prompt: "What is the capital of France?", + Output: &v1alpha2.StepOutput{As: "agentResult"}, + }, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-agent-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + + finalRun := waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseSucceeded), 180*time.Second) + require.Len(t, finalRun.Status.Steps, 1) + assert.Equal(t, string(v1alpha2.StepPhaseSucceeded), string(finalRun.Status.Steps[0].Phase)) +} + +// TestE2EWorkflowFailFast verifies that when a step with onFailure=stop fails, +// dependent steps are skipped and the workflow reaches Failed phase. +func TestE2EWorkflowFailFast(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-failfast-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Fail-fast test: B fails, C should be skipped", + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", With: map[string]string{"val": "ok"}}, + { + Name: "step-b", + Type: v1alpha2.StepTypeAction, + Action: "fail.always", + DependsOn: []string{"step-a"}, + OnFailure: "stop", + Policy: &v1alpha2.StepPolicy{ + Retry: &v1alpha2.WorkflowRetryPolicy{MaxAttempts: 1}, + }, + }, + {Name: "step-c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-b"}, With: map[string]string{"val": "should-not-run"}}, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-failfast-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + + finalRun := waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseFailed), 120*time.Second) + require.Len(t, finalRun.Status.Steps, 3) + + stepPhases := map[string]string{} + for _, s := range finalRun.Status.Steps { + stepPhases[s.Name] = string(s.Phase) + } + assert.Equal(t, string(v1alpha2.StepPhaseSucceeded), stepPhases["step-a"]) + assert.Equal(t, string(v1alpha2.StepPhaseFailed), stepPhases["step-b"]) + assert.Equal(t, string(v1alpha2.StepPhaseSkipped), stepPhases["step-c"]) +} + +// TestE2EWorkflowRetry verifies that a step with retry policy retries on failure. +func TestE2EWorkflowRetry(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-retry-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Retry test: step retries 3 times", + Steps: []v1alpha2.StepSpec{ + { + Name: "retry-step", + Type: v1alpha2.StepTypeAction, + Action: "noop", + With: map[string]string{"val": "retry-test"}, + Policy: &v1alpha2.StepPolicy{ + Retry: &v1alpha2.WorkflowRetryPolicy{ + MaxAttempts: 3, + InitialInterval: metav1.Duration{Duration: 1 * time.Second}, + }, + }, + }, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-retry-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + + // With noop action, should succeed on first attempt. + finalRun := waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseSucceeded), 120*time.Second) + require.Len(t, finalRun.Status.Steps, 1) + assert.Equal(t, string(v1alpha2.StepPhaseSucceeded), string(finalRun.Status.Steps[0].Phase)) +} + +// TestE2EWorkflowCancellation verifies that deleting a WorkflowRun cancels +// the Temporal workflow and the finalizer is removed. +func TestE2EWorkflowCancellation(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-cancel-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Cancellation test", + Steps: []v1alpha2.StepSpec{ + { + Name: "long-step", + Type: v1alpha2.StepTypeAction, + Action: "noop", + With: map[string]string{"val": "cancel-me"}, + Policy: &v1alpha2.StepPolicy{ + Timeout: &v1alpha2.WorkflowTimeoutPolicy{ + StartToClose: metav1.Duration{Duration: 30 * time.Minute}, + }, + }, + }, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-cancel-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + + // Wait for run to be accepted and running. + waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseRunning), 60*time.Second) + + // Delete the run. The finalizer should cancel the Temporal workflow. + err := cli.Delete(t.Context(), run) + require.NoError(t, err) + + // Verify the run is deleted (finalizer removed). + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + var deleted v1alpha2.WorkflowRun + err := cli.Get(ctx, client.ObjectKeyFromObject(run), &deleted) + if err != nil { + return true, nil // Not found = deleted + } + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for WorkflowRun to be fully deleted") +} + +// TestE2EWorkflowRetention verifies that the retention controller enforces +// successfulRunsHistoryLimit by deleting oldest completed runs. +func TestE2EWorkflowRetention(t *testing.T) { + skipIfNoWorkflows(t) + skipIfNoTemporal(t) + waitForTemporalReady(t) + waitForNATSReady(t) + + cli := setupK8sClient(t, false) + + limit := int32(2) + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-retention-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Retention test", + Retention: &v1alpha2.RetentionPolicy{ + SuccessfulRunsHistoryLimit: &limit, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "simple", Type: v1alpha2.StepTypeAction, Action: "noop", With: map[string]string{"val": "ok"}}, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + // Create 4 runs — retention should keep only 2. + runNames := make([]string, 4) + for i := 0; i < 4; i++ { + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("wf-ret-run-%s-%d", tmpl.Name, i), + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + }, + }) + runNames[i] = run.Name + // Wait for each to succeed before creating next. + waitForRunPhase(t, cli, client.ObjectKeyFromObject(run), string(v1alpha2.WorkflowRunPhaseSucceeded), 120*time.Second) + // Small delay to ensure distinct completion times. + time.Sleep(2 * time.Second) + } + + // Wait for retention controller to clean up (runs every 60s). + t.Log("Waiting for retention controller to enforce history limits...") + var remainingRuns int + pollErr := wait.PollUntilContextTimeout(t.Context(), 10*time.Second, 180*time.Second, true, func(ctx context.Context) (bool, error) { + runList := &v1alpha2.WorkflowRunList{} + if err := cli.List(ctx, runList, client.InNamespace("kagent")); err != nil { + return false, nil + } + count := 0 + for _, r := range runList.Items { + if r.Spec.WorkflowTemplateRef == tmpl.Name { + count++ + } + } + remainingRuns = count + t.Logf("Retention check: %d runs remaining for template %s (limit=%d)", count, tmpl.Name, limit) + return count <= int(limit), nil + }) + require.NoError(t, pollErr, "timed out waiting for retention controller (remaining: %d, limit: %d)", remainingRuns, limit) + assert.LessOrEqual(t, remainingRuns, int(limit)) +} + +// TestE2EWorkflowCycleDetection verifies that a WorkflowTemplate with a +// dependency cycle is rejected with CycleDetected reason. +func TestE2EWorkflowCycleDetection(t *testing.T) { + skipIfNoWorkflows(t) + + cli := setupK8sClient(t, false) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-cycle-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Cycle detection test", + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-c"}}, + {Name: "step-b", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-a"}}, + {Name: "step-c", Type: v1alpha2.StepTypeAction, Action: "noop", DependsOn: []string{"step-b"}}, + }, + }, + }) + + waitForTemplateRejected(t, cli, client.ObjectKeyFromObject(tmpl), "CycleDetected") +} + +// TestE2EWorkflowMissingParam verifies that a WorkflowRun with a missing required +// parameter is rejected (Accepted=False) without starting a Temporal workflow. +func TestE2EWorkflowMissingParam(t *testing.T) { + skipIfNoWorkflows(t) + + cli := setupK8sClient(t, false) + + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-param-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "Missing param test", + Params: []v1alpha2.ParamSpec{ + {Name: "required-param", Type: v1alpha2.ParamTypeString}, + }, + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", With: map[string]string{"val": "${{ params.required-param }}"}}, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + // Create run WITHOUT the required param. + run := createWorkflowRun(t, cli, &v1alpha2.WorkflowRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-param-run-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowRunSpec{ + WorkflowTemplateRef: tmpl.Name, + // No params provided. + }, + }) + + // Run should be rejected. + var finalRun v1alpha2.WorkflowRun + pollErr := wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + if err := cli.Get(ctx, client.ObjectKeyFromObject(run), &finalRun); err != nil { + return false, nil + } + for _, c := range finalRun.Status.Conditions { + if c.Type == v1alpha2.WorkflowRunConditionAccepted && c.Status == metav1.ConditionFalse { + return true, nil + } + } + return false, nil + }) + require.NoError(t, pollErr, "timed out waiting for WorkflowRun to be rejected") + assert.Empty(t, finalRun.Status.TemporalWorkflowID, "Temporal workflow should not have been started") +} + +// TestE2EWorkflowAPIEndpoints verifies the HTTP API for workflow CRUD operations. +func TestE2EWorkflowAPIEndpoints(t *testing.T) { + skipIfNoWorkflows(t) + + cli := setupK8sClient(t, false) + baseURL := kagentBaseURL() + httpClient := &http.Client{Timeout: 10 * time.Second} + + // Create a template via K8s API first. + tmpl := createWorkflowTemplate(t, cli, &v1alpha2.WorkflowTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "wf-api-test-", + Namespace: "kagent", + }, + Spec: v1alpha2.WorkflowTemplateSpec{ + Description: "API test template", + Steps: []v1alpha2.StepSpec{ + {Name: "step-a", Type: v1alpha2.StepTypeAction, Action: "noop", With: map[string]string{"val": "api-test"}}, + }, + }, + }) + + waitForTemplateValidated(t, cli, client.ObjectKeyFromObject(tmpl)) + + t.Run("list_templates", func(t *testing.T) { + resp, err := httpClient.Get(baseURL + "/api/workflow-templates") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), tmpl.Name) + }) + + t.Run("get_template", func(t *testing.T) { + url := fmt.Sprintf("%s/api/workflow-templates/%s/%s", baseURL, tmpl.Namespace, tmpl.Name) + resp, err := httpClient.Get(url) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("create_run_via_api", func(t *testing.T) { + reqBody := api.CreateWorkflowRunRequest{ + Name: "wf-api-run-test", + Namespace: "kagent", + WorkflowTemplateRef: tmpl.Name, + } + body, _ := json.Marshal(reqBody) + resp, err := httpClient.Post(baseURL+"/api/workflow-runs", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + // Cleanup + t.Cleanup(func() { + run := &v1alpha2.WorkflowRun{ObjectMeta: metav1.ObjectMeta{Name: "wf-api-run-test", Namespace: "kagent"}} + cli.Delete(context.Background(), run) //nolint:errcheck + }) + }) + + t.Run("list_runs", func(t *testing.T) { + resp, err := httpClient.Get(baseURL + "/api/workflow-runs") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("get_run", func(t *testing.T) { + url := fmt.Sprintf("%s/api/workflow-runs/%s/%s", baseURL, "kagent", "wf-api-run-test") + resp, err := httpClient.Get(url) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("delete_run", func(t *testing.T) { + url := fmt.Sprintf("%s/api/workflow-runs/%s/%s", baseURL, "kagent", "wf-api-run-test") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodDelete, url, nil) + resp, err := httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("get_template_not_found", func(t *testing.T) { + url := fmt.Sprintf("%s/api/workflow-templates/%s/%s", baseURL, "kagent", "nonexistent") + resp, err := httpClient.Get(url) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} diff --git a/go/go.work b/go/go.work index 85d62289c..d346906a2 100644 --- a/go/go.work +++ b/go/go.work @@ -4,4 +4,9 @@ use ( ./api ./core ./adk + ./plugins/kanban-mcp + ./plugins/gitrepo-mcp + ./plugins/temporal-mcp + ./plugins/nats-activity-feed + ./plugins/cron-mcp ) diff --git a/go/go.work.sum b/go/go.work.sum index 22b975a3b..662a1b203 100644 --- a/go/go.work.sum +++ b/go/go.work.sum @@ -1,5 +1,9 @@ ariga.io/atlas v0.32.0 h1:y+77nueMrExLiKlz1CcPKh/nU7VSlWfBbwCShsJyvCw= ariga.io/atlas v0.32.0/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= +buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew= +buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -333,6 +337,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= @@ -459,8 +465,6 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198 h1:FSii2UQeSLngl3jFoR4tUKZLprO7qUlh/TKKticc0BM= github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198/go.mod h1:DTh/Y2+NbnOVVoypCCQrovMPDKUGp4yZpSbWg5D0XIM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -518,6 +522,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -561,6 +567,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -571,6 +579,7 @@ github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 h1:mV02weKRL81bEnm8A0HT1/CAelMQDBuQIfLw8n+d6xI= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -586,8 +595,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -634,11 +643,11 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= @@ -650,7 +659,6 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= @@ -658,9 +666,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= @@ -698,6 +705,8 @@ go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= @@ -708,6 +717,8 @@ go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Z go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= @@ -737,11 +748,8 @@ go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4Etq go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -753,10 +761,6 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -790,31 +794,28 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -824,12 +825,12 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= @@ -838,17 +839,12 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -866,21 +862,16 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -902,8 +893,6 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -911,6 +900,7 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -929,15 +919,14 @@ golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -947,14 +936,12 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -978,7 +965,6 @@ golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -1008,6 +994,7 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= @@ -1017,10 +1004,6 @@ golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+V golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/plot v0.15.2 h1:Tlfh/jBk2tqjLZ4/P8ZIwGrLEWQSPDLRm/SNWKNXiGI= @@ -1109,6 +1092,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= google.golang.org/genproto/googleapis/api v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:dbWfpVPvW/RqafStmRWBUpMN14puDezDMHxNYiRfQu0= google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/bytestream v0.0.0-20251002232023-7c0ddcbb5797 h1:Rw7vkrrOFdC5zerfwcdnDnxf6qQuRjmvdqy7kY2Cr7o= google.golang.org/genproto/googleapis/bytestream v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:YUQUKndxDbAanQC0ln4pZ3Sis3N5sqgDte2XQqufkJc= google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= @@ -1126,7 +1111,9 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1175,7 +1162,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= @@ -1188,6 +1174,7 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/go/nats-activity-feed b/go/nats-activity-feed new file mode 100755 index 000000000..a67eff844 Binary files /dev/null and b/go/nats-activity-feed differ diff --git a/go/plugins/cron-mcp/Dockerfile b/go/plugins/cron-mcp/Dockerfile new file mode 100644 index 000000000..86868aec6 --- /dev/null +++ b/go/plugins/cron-mcp/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go/ ./go/ +WORKDIR /app/go +RUN go build -o cron-mcp ./plugins/cron-mcp + +FROM alpine:3.20 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /app/go/cron-mcp /usr/local/bin/cron-mcp +ENTRYPOINT ["cron-mcp"] diff --git a/go/plugins/cron-mcp/go.mod b/go/plugins/cron-mcp/go.mod new file mode 100644 index 000000000..5e862486d --- /dev/null +++ b/go/plugins/cron-mcp/go.mod @@ -0,0 +1,38 @@ +module github.com/kagent-dev/kagent/go/plugins/cron-mcp + +go 1.25.7 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/robfig/cron/v3 v3.0.1 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.26.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.20.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go/plugins/cron-mcp/go.sum b/go/plugins/cron-mcp/go.sum new file mode 100644 index 000000000..fcef8dc28 --- /dev/null +++ b/go/plugins/cron-mcp/go.sum @@ -0,0 +1,82 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= +gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/go/plugins/cron-mcp/internal/api/handlers.go b/go/plugins/cron-mcp/internal/api/handlers.go new file mode 100644 index 000000000..2fa62a09a --- /dev/null +++ b/go/plugins/cron-mcp/internal/api/handlers.go @@ -0,0 +1,313 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/scheduler" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/service" + "gorm.io/gorm" +) + +// Board groups jobs by status. +type Board struct { + Groups []Group `json:"groups"` +} + +// Group holds jobs for a single status. +type Group struct { + Status string `json:"status"` + Jobs []*db.CronJob `json:"jobs"` +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) //nolint:errcheck +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func httpStatus(err error) int { + if errors.Is(err, gorm.ErrRecordNotFound) { + return http.StatusNotFound + } + msg := err.Error() + if strings.Contains(msg, "invalid status") { + return http.StatusBadRequest + } + return http.StatusInternalServerError +} + +func parseID(path, prefix string) (uint, string, bool) { + trimmed := strings.TrimPrefix(path, prefix) + parts := strings.SplitN(trimmed, "/", 2) + id, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return 0, "", false + } + suffix := "" + if len(parts) > 1 { + suffix = "/" + parts[1] + } + return uint(id), suffix, true +} + +// JobsHandler handles /api/jobs (GET list, POST create). +func JobsHandler(svc *service.CronService, sched *scheduler.Scheduler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + filter := service.JobFilter{} + if s := r.URL.Query().Get("status"); s != "" { + js := db.JobStatus(s) + filter.Status = &js + } + if l := r.URL.Query().Get("label"); l != "" { + filter.Label = &l + } + jobs, err := svc.ListJobs(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, jobs) + + case http.MethodPost: + var body struct { + Name string `json:"name"` + Description string `json:"description"` + Schedule string `json:"schedule"` + Command string `json:"command"` + Labels []string `json:"labels"` + Timeout int `json:"timeout"` + MaxRetries int `json:"max_retries"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + req := service.CreateJobRequest{ + Name: body.Name, + Description: body.Description, + Schedule: body.Schedule, + Command: body.Command, + Labels: body.Labels, + Timeout: body.Timeout, + MaxRetries: body.MaxRetries, + } + job, err := svc.CreateJob(r.Context(), req) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + if sched != nil { + sched.AddJob(job) + } + writeJSON(w, http.StatusCreated, job) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +// JobHandler handles /api/jobs/{id}, /api/jobs/{id}/run, /api/jobs/{id}/toggle, /api/jobs/{id}/executions. +func JobHandler(svc *service.CronService, sched *scheduler.Scheduler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, suffix, ok := parseID(r.URL.Path, "/api/jobs/") + if !ok { + http.NotFound(w, r) + return + } + + switch suffix { + case "/run": + handleRunJob(w, r, svc, sched, id) + case "/toggle": + handleToggleJob(w, r, svc, sched, id) + case "/executions": + handleExecutions(w, r, svc, id) + case "": + handleJob(w, r, svc, sched, id) + default: + http.NotFound(w, r) + } + } +} + +// ExecutionHandler handles /api/executions/{id}. +func ExecutionHandler(svc *service.CronService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, "/api/executions/") + eid, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + exec, err := svc.GetExecution(r.Context(), uint(eid)) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, exec) + } +} + +func handleJob(w http.ResponseWriter, r *http.Request, svc *service.CronService, sched *scheduler.Scheduler, id uint) { + switch r.Method { + case http.MethodGet: + job, err := svc.GetJob(r.Context(), id) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, job) + + case http.MethodPut: + var body struct { + Name *string `json:"name"` + Description *string `json:"description"` + Schedule *string `json:"schedule"` + Command *string `json:"command"` + Status *string `json:"status"` + Labels *[]string `json:"labels"` + Timeout *int `json:"timeout"` + MaxRetries *int `json:"max_retries"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + req := service.UpdateJobRequest{ + Name: body.Name, + Description: body.Description, + Schedule: body.Schedule, + Command: body.Command, + Labels: body.Labels, + Timeout: body.Timeout, + MaxRetries: body.MaxRetries, + } + if body.Status != nil { + s := db.JobStatus(*body.Status) + req.Status = &s + } + job, err := svc.UpdateJob(r.Context(), id, req) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + if sched != nil { + sched.AddJob(job) + } + writeJSON(w, http.StatusOK, job) + + case http.MethodDelete: + if sched != nil { + sched.RemoveJob(id) + } + if err := svc.DeleteJob(r.Context(), id); err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleRunJob(w http.ResponseWriter, r *http.Request, svc *service.CronService, sched *scheduler.Scheduler, id uint) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + job, err := svc.GetJob(r.Context(), id) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + if sched != nil { + sched.RunNow(job.ID, job.Command, job.Timeout) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"triggered": true, "id": id}) +} + +func handleToggleJob(w http.ResponseWriter, r *http.Request, svc *service.CronService, sched *scheduler.Scheduler, id uint) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + job, err := svc.ToggleJob(r.Context(), id) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + if sched != nil { + sched.AddJob(job) + } + writeJSON(w, http.StatusOK, job) +} + +func handleExecutions(w http.ResponseWriter, r *http.Request, svc *service.CronService, jobID uint) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = n + } + } + execs, err := svc.ListExecutions(r.Context(), jobID, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, execs) +} + +// BoardHandler handles GET /api/board. +func BoardHandler(svc *service.CronService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + jobs, err := svc.GetAllJobs(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + byStatus := make(map[db.JobStatus][]*db.CronJob) + for _, j := range jobs { + byStatus[j.Status] = append(byStatus[j.Status], j) + } + + groups := make([]Group, 0, len(db.StatusList)) + for _, status := range db.StatusList { + g := Group{ + Status: string(status), + Jobs: byStatus[status], + } + if g.Jobs == nil { + g.Jobs = []*db.CronJob{} + } + groups = append(groups, g) + } + + writeJSON(w, http.StatusOK, Board{Groups: groups}) + } +} diff --git a/go/plugins/cron-mcp/internal/config/config.go b/go/plugins/cron-mcp/internal/config/config.go new file mode 100644 index 000000000..956cd5fe0 --- /dev/null +++ b/go/plugins/cron-mcp/internal/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "flag" + "os" +) + +// DBType represents the database backend type. +type DBType string + +const ( + DBTypeSQLite DBType = "sqlite" + DBTypePostgres DBType = "postgres" +) + +// Config holds all runtime settings for the cron-mcp server. +type Config struct { + Addr string // --addr / CRON_ADDR, default ":8080" + Transport string // --transport / CRON_TRANSPORT, "http" | "stdio" + DBType DBType // --db-type / CRON_DB_TYPE, "sqlite" | "postgres" + DBPath string // --db-path / CRON_DB_PATH, default "./cron.db" + DBURL string // --db-url / CRON_DB_URL + LogLevel string // --log-level / CRON_LOG_LEVEL, default "info" + Shell string // --shell / CRON_SHELL, default "/bin/sh" +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// Load parses CLI flags (os.Args[1:]) with CRON_* environment variable fallback. +func Load() (*Config, error) { + return LoadArgs(os.Args[1:]) +} + +// LoadArgs parses the given args with CRON_* environment variable fallback. +func LoadArgs(args []string) (*Config, error) { + fs := flag.NewFlagSet("cron-mcp", flag.ContinueOnError) + + addr := fs.String("addr", envOrDefault("CRON_ADDR", ":8080"), "listen address") + transport := fs.String("transport", envOrDefault("CRON_TRANSPORT", "http"), "transport mode: http or stdio") + dbType := fs.String("db-type", envOrDefault("CRON_DB_TYPE", "sqlite"), "database type: sqlite or postgres") + dbPath := fs.String("db-path", envOrDefault("CRON_DB_PATH", "./cron.db"), "SQLite database file path") + dbURL := fs.String("db-url", envOrDefault("CRON_DB_URL", ""), "Postgres connection URL") + logLevel := fs.String("log-level", envOrDefault("CRON_LOG_LEVEL", "info"), "log level: debug, info, warn, error") + shell := fs.String("shell", envOrDefault("CRON_SHELL", "/bin/sh"), "shell to execute commands") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + return &Config{ + Addr: *addr, + Transport: *transport, + DBType: DBType(*dbType), + DBPath: *dbPath, + DBURL: *dbURL, + LogLevel: *logLevel, + Shell: *shell, + }, nil +} diff --git a/go/plugins/cron-mcp/internal/db/manager.go b/go/plugins/cron-mcp/internal/db/manager.go new file mode 100644 index 000000000..09fd4d1b9 --- /dev/null +++ b/go/plugins/cron-mcp/internal/db/manager.go @@ -0,0 +1,55 @@ +package db + +import ( + "fmt" + + "github.com/glebarez/sqlite" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/config" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Manager handles database connection and initialization. +type Manager struct { + db *gorm.DB +} + +// NewManager creates a new database manager based on the provided config. +func NewManager(cfg *config.Config) (*Manager, error) { + var db *gorm.DB + var err error + + gormCfg := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + TranslateError: true, + } + + switch cfg.DBType { + case config.DBTypeSQLite: + db, err = gorm.Open(sqlite.Open(cfg.DBPath), gormCfg) + case config.DBTypePostgres: + db, err = gorm.Open(postgres.Open(cfg.DBURL), gormCfg) + default: + return nil, fmt.Errorf("invalid database type: %s", cfg.DBType) + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + return &Manager{db: db}, nil +} + +// Initialize runs AutoMigrate for the CronJob and Execution models. +func (m *Manager) Initialize() error { + if err := m.db.AutoMigrate(&CronJob{}, &Execution{}); err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + return nil +} + +// DB returns the underlying *gorm.DB instance. +func (m *Manager) DB() *gorm.DB { + return m.db +} diff --git a/go/plugins/cron-mcp/internal/db/models.go b/go/plugins/cron-mcp/internal/db/models.go new file mode 100644 index 000000000..3fe9797e5 --- /dev/null +++ b/go/plugins/cron-mcp/internal/db/models.go @@ -0,0 +1,114 @@ +package db + +import ( + "database/sql/driver" + "encoding/json" + "time" +) + +// JobStatus represents the current state of a cron job. +type JobStatus string + +const ( + StatusActive JobStatus = "Active" + StatusPaused JobStatus = "Paused" + StatusError JobStatus = "Error" + StatusArchived JobStatus = "Archived" +) + +// StatusList defines all valid job statuses. +var StatusList = []JobStatus{ + StatusActive, + StatusPaused, + StatusError, + StatusArchived, +} + +// ValidStatus returns true if s is a valid job status. +func ValidStatus(s JobStatus) bool { + for _, v := range StatusList { + if v == s { + return true + } + } + return false +} + +// ExecStatus represents the status of a single execution. +type ExecStatus string + +const ( + ExecRunning ExecStatus = "Running" + ExecSuccess ExecStatus = "Success" + ExecFailed ExecStatus = "Failed" +) + +// StringSlice is a custom type for storing string slices as JSON in the database. +type StringSlice []string + +// Scan implements the sql.Scanner interface for StringSlice. +func (s *StringSlice) Scan(value interface{}) error { + if value == nil { + *s = nil + return nil + } + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + *s = nil + return nil + } + if len(bytes) == 0 || string(bytes) == "null" { + *s = nil + return nil + } + return json.Unmarshal(bytes, s) +} + +// Value implements the driver.Valuer interface for StringSlice. +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + data, err := json.Marshal(s) + if err != nil { + return nil, err + } + return string(data), nil +} + +// CronJob is the GORM model for a cron job definition. +type CronJob struct { + ID uint `gorm:"primarykey"` + Name string `gorm:"not null"` + Description string + Schedule string `gorm:"not null"` // cron expression e.g. "*/5 * * * *" + Command string `gorm:"not null;type:text"` + Status JobStatus `gorm:"not null;default:'Active'"` + Labels StringSlice `gorm:"type:text"` + Timeout int `gorm:"not null;default:300"` // seconds + MaxRetries int `gorm:"not null;default:0"` + LastRunAt *time.Time + LastRunStatus *ExecStatus + NextRunAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + Executions []*Execution `gorm:"foreignKey:CronJobID"` +} + +// Execution is the GORM model for a single cron job execution. +type Execution struct { + ID uint `gorm:"primarykey"` + CronJobID uint `gorm:"not null;index"` + Status ExecStatus `gorm:"not null;default:'Running'"` + Output string `gorm:"type:text"` + ExitCode *int + StartedAt time.Time `gorm:"not null"` + FinishedAt *time.Time + Duration *float64 // seconds + CreatedAt time.Time +} diff --git a/go/plugins/cron-mcp/internal/mcp/tools.go b/go/plugins/cron-mcp/internal/mcp/tools.go new file mode 100644 index 000000000..6f22567f5 --- /dev/null +++ b/go/plugins/cron-mcp/internal/mcp/tools.go @@ -0,0 +1,330 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/scheduler" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/service" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Board is the response for get_board, grouping jobs by status. +type Board struct { + Groups []Group `json:"groups"` +} + +// Group holds jobs for a single status. +type Group struct { + Status string `json:"status"` + Jobs []*db.CronJob `json:"jobs"` +} + +// NewServer creates and returns an MCP server with all cron tools registered. +func NewServer(svc *service.CronService, sched *scheduler.Scheduler) *mcpsdk.Server { + server := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "cron", + Version: "v1.0.0", + }, nil) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_jobs", + Description: "List cron jobs, optionally filtered by status or label.", + }, handleListJobs(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_job", + Description: "Get a cron job by ID including recent executions.", + }, handleGetJob(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "create_job", + Description: "Create a new cron job with a schedule (cron expression) and command.", + }, handleCreateJob(svc, sched)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "update_job", + Description: "Update cron job fields (name, description, schedule, command, status, labels, timeout, max_retries).", + }, handleUpdateJob(svc, sched)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "toggle_job", + Description: "Toggle a job between Active and Paused status.", + }, handleToggleJob(svc, sched)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_job", + Description: "Delete a cron job and all its execution history.", + }, handleDeleteJob(svc, sched)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "run_job", + Description: "Manually trigger a cron job execution immediately.", + }, handleRunJob(svc, sched)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_executions", + Description: "List recent executions for a cron job.", + }, handleListExecutions(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_execution", + Description: "Get a single execution by ID with full output.", + }, handleGetExecution(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_board", + Description: "Get all cron jobs grouped by status.", + }, handleGetBoard(svc)) + + return server +} + +func textResult(v interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + data, err := json.Marshal(v) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + } + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: string(data)}, + }, + }, nil, nil +} + +func errorResult(msg string) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: msg}, + }, + } +} + +// --- Tool input types --- + +type listJobsInput struct { + Status string `json:"status,omitempty"` + Label string `json:"label,omitempty"` +} + +type getJobInput struct { + ID uint `json:"id"` +} + +type createJobInput struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schedule string `json:"schedule"` + Command string `json:"command"` + Labels []string `json:"labels,omitempty"` + Timeout int `json:"timeout,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` +} + +type updateJobInput struct { + ID uint `json:"id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Schedule *string `json:"schedule,omitempty"` + Command *string `json:"command,omitempty"` + Status *string `json:"status,omitempty"` + Labels *[]string `json:"labels,omitempty"` + Timeout *int `json:"timeout,omitempty"` + MaxRetries *int `json:"max_retries,omitempty"` +} + +type toggleJobInput struct { + ID uint `json:"id"` +} + +type deleteJobInput struct { + ID uint `json:"id"` +} + +type runJobInput struct { + ID uint `json:"id"` +} + +type listExecutionsInput struct { + JobID uint `json:"job_id"` + Limit int `json:"limit,omitempty"` +} + +type getExecutionInput struct { + ID uint `json:"id"` +} + +// --- Tool handlers --- + +func handleListJobs(svc *service.CronService) func(context.Context, *mcpsdk.CallToolRequest, listJobsInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input listJobsInput) (*mcpsdk.CallToolResult, interface{}, error) { + filter := service.JobFilter{} + if input.Status != "" { + s := db.JobStatus(input.Status) + filter.Status = &s + } + if input.Label != "" { + filter.Label = &input.Label + } + jobs, err := svc.ListJobs(ctx, filter) + if err != nil { + return errorResult(fmt.Sprintf("list_jobs failed: %v", err)), nil, nil + } + return textResult(jobs) + } +} + +func handleGetJob(svc *service.CronService) func(context.Context, *mcpsdk.CallToolRequest, getJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input getJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + job, err := svc.GetJob(ctx, input.ID) + if err != nil { + return errorResult(fmt.Sprintf("get_job failed: %v", err)), nil, nil + } + return textResult(job) + } +} + +func handleCreateJob(svc *service.CronService, sched *scheduler.Scheduler) func(context.Context, *mcpsdk.CallToolRequest, createJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input createJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.CreateJobRequest{ + Name: input.Name, + Description: input.Description, + Schedule: input.Schedule, + Command: input.Command, + Labels: input.Labels, + Timeout: input.Timeout, + MaxRetries: input.MaxRetries, + } + job, err := svc.CreateJob(ctx, req) + if err != nil { + return errorResult(fmt.Sprintf("create_job failed: %v", err)), nil, nil + } + if sched != nil { + sched.AddJob(job) + } + return textResult(job) + } +} + +func handleUpdateJob(svc *service.CronService, sched *scheduler.Scheduler) func(context.Context, *mcpsdk.CallToolRequest, updateJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input updateJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.UpdateJobRequest{ + Name: input.Name, + Description: input.Description, + Schedule: input.Schedule, + Command: input.Command, + Labels: input.Labels, + Timeout: input.Timeout, + MaxRetries: input.MaxRetries, + } + if input.Status != nil { + s := db.JobStatus(*input.Status) + req.Status = &s + } + job, err := svc.UpdateJob(ctx, input.ID, req) + if err != nil { + return errorResult(fmt.Sprintf("update_job failed: %v", err)), nil, nil + } + if sched != nil { + sched.AddJob(job) + } + return textResult(job) + } +} + +func handleToggleJob(svc *service.CronService, sched *scheduler.Scheduler) func(context.Context, *mcpsdk.CallToolRequest, toggleJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input toggleJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + job, err := svc.ToggleJob(ctx, input.ID) + if err != nil { + return errorResult(fmt.Sprintf("toggle_job failed: %v", err)), nil, nil + } + if sched != nil { + sched.AddJob(job) + } + return textResult(job) + } +} + +func handleDeleteJob(svc *service.CronService, sched *scheduler.Scheduler) func(context.Context, *mcpsdk.CallToolRequest, deleteJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input deleteJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + if sched != nil { + sched.RemoveJob(input.ID) + } + if err := svc.DeleteJob(ctx, input.ID); err != nil { + return errorResult(fmt.Sprintf("delete_job failed: %v", err)), nil, nil + } + return textResult(map[string]interface{}{"deleted": true, "id": input.ID}) + } +} + +func handleRunJob(svc *service.CronService, sched *scheduler.Scheduler) func(context.Context, *mcpsdk.CallToolRequest, runJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input runJobInput) (*mcpsdk.CallToolResult, interface{}, error) { + job, err := svc.GetJob(ctx, input.ID) + if err != nil { + return errorResult(fmt.Sprintf("run_job failed: %v", err)), nil, nil + } + if sched != nil { + sched.RunNow(job.ID, job.Command, job.Timeout) + } + return textResult(map[string]interface{}{"triggered": true, "id": input.ID}) + } +} + +func handleListExecutions(svc *service.CronService) func(context.Context, *mcpsdk.CallToolRequest, listExecutionsInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input listExecutionsInput) (*mcpsdk.CallToolResult, interface{}, error) { + execs, err := svc.ListExecutions(ctx, input.JobID, input.Limit) + if err != nil { + return errorResult(fmt.Sprintf("list_executions failed: %v", err)), nil, nil + } + return textResult(execs) + } +} + +func handleGetExecution(svc *service.CronService) func(context.Context, *mcpsdk.CallToolRequest, getExecutionInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input getExecutionInput) (*mcpsdk.CallToolResult, interface{}, error) { + exec, err := svc.GetExecution(ctx, input.ID) + if err != nil { + return errorResult(fmt.Sprintf("get_execution failed: %v", err)), nil, nil + } + return textResult(exec) + } +} + +func handleGetBoard(svc *service.CronService) func(context.Context, *mcpsdk.CallToolRequest, interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + board, err := buildBoard(ctx, svc) + if err != nil { + return errorResult(fmt.Sprintf("get_board failed: %v", err)), nil, nil + } + return textResult(board) + } +} + +func buildBoard(ctx context.Context, svc *service.CronService) (*Board, error) { + jobs, err := svc.GetAllJobs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list jobs: %w", err) + } + + byStatus := make(map[db.JobStatus][]*db.CronJob) + for _, j := range jobs { + byStatus[j.Status] = append(byStatus[j.Status], j) + } + + groups := make([]Group, 0, len(db.StatusList)) + for _, status := range db.StatusList { + g := Group{ + Status: string(status), + Jobs: byStatus[status], + } + if g.Jobs == nil { + g.Jobs = []*db.CronJob{} + } + groups = append(groups, g) + } + + return &Board{Groups: groups}, nil +} diff --git a/go/plugins/cron-mcp/internal/scheduler/scheduler.go b/go/plugins/cron-mcp/internal/scheduler/scheduler.go new file mode 100644 index 000000000..bdd3c2624 --- /dev/null +++ b/go/plugins/cron-mcp/internal/scheduler/scheduler.go @@ -0,0 +1,180 @@ +package scheduler + +import ( + "bytes" + "context" + "fmt" + "log" + "os/exec" + "sync" + "time" + + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/service" + "github.com/robfig/cron/v3" +) + +// Scheduler manages cron job scheduling and execution. +type Scheduler struct { + svc *service.CronService + cron *cron.Cron + shell string + mu sync.Mutex + // maps job ID → cron entry ID so we can remove/update + entries map[uint]cron.EntryID +} + +// New creates a new Scheduler. +func New(svc *service.CronService, shell string) *Scheduler { + return &Scheduler{ + svc: svc, + cron: cron.New(cron.WithSeconds()), + shell: shell, + entries: make(map[uint]cron.EntryID), + } +} + +// Start loads all active jobs from the database and starts the scheduler. +func (s *Scheduler) Start(ctx context.Context) error { + jobs, err := s.svc.ListJobs(ctx, service.JobFilter{}) + if err != nil { + return fmt.Errorf("failed to load jobs: %w", err) + } + + for _, job := range jobs { + if job.Status == db.StatusActive { + s.AddJob(job) + } + } + + s.cron.Start() + return nil +} + +// Stop stops the cron scheduler. +func (s *Scheduler) Stop() { + s.cron.Stop() +} + +// AddJob registers a job with the cron scheduler. +func (s *Scheduler) AddJob(job *db.CronJob) { + s.mu.Lock() + defer s.mu.Unlock() + + // Remove existing entry if any + if entryID, ok := s.entries[job.ID]; ok { + s.cron.Remove(entryID) + delete(s.entries, job.ID) + } + + if job.Status != db.StatusActive { + return + } + + jobID := job.ID + command := job.Command + timeout := job.Timeout + if timeout <= 0 { + timeout = 300 + } + + entryID, err := s.cron.AddFunc(job.Schedule, func() { + s.executeJob(jobID, command, timeout) + }) + if err != nil { + log.Printf("failed to schedule job %d (%s): %v", job.ID, job.Schedule, err) + return + } + + s.entries[job.ID] = entryID + + // Update next run time + entry := s.cron.Entry(entryID) + if !entry.Next.IsZero() { + next := entry.Next + ctx := context.Background() + s.svc.UpdateJob(ctx, job.ID, service.UpdateJobRequest{}) //nolint:errcheck + // Direct DB update for next_run_at + s.updateNextRun(job.ID, &next) + } +} + +// RemoveJob removes a job from the scheduler. +func (s *Scheduler) RemoveJob(jobID uint) { + s.mu.Lock() + defer s.mu.Unlock() + + if entryID, ok := s.entries[jobID]; ok { + s.cron.Remove(entryID) + delete(s.entries, jobID) + } +} + +// RunNow manually triggers a job execution. +func (s *Scheduler) RunNow(jobID uint, command string, timeout int) { + go s.executeJob(jobID, command, timeout) +} + +func (s *Scheduler) executeJob(jobID uint, command string, timeout int) { + ctx := context.Background() + + execution, err := s.svc.StartExecution(ctx, jobID) + if err != nil { + log.Printf("failed to start execution for job %d: %v", jobID, err) + return + } + + timeoutDur := time.Duration(timeout) * time.Second + execCtx, cancel := context.WithTimeout(ctx, timeoutDur) + defer cancel() + + cmd := exec.CommandContext(execCtx, s.shell, "-c", command) + var outBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &outBuf + + err = cmd.Run() + + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + outBuf.WriteString("\n" + err.Error()) + } + } + + // Truncate output to 64KB + output := outBuf.String() + if len(output) > 65536 { + output = output[:65536] + "\n... (truncated)" + } + + if _, err := s.svc.FinishExecution(ctx, execution.ID, output, exitCode); err != nil { + log.Printf("failed to finish execution %d: %v", execution.ID, err) + } + + // Update next run time + s.mu.Lock() + if entryID, ok := s.entries[jobID]; ok { + entry := s.cron.Entry(entryID) + if !entry.Next.IsZero() { + next := entry.Next + s.updateNextRun(jobID, &next) + } + } + s.mu.Unlock() +} + +func (s *Scheduler) updateNextRun(jobID uint, next *time.Time) { + // Use a raw update to set next_run_at without triggering full model save + ctx := context.Background() + job, err := s.svc.GetJob(ctx, jobID) + if err != nil { + return + } + job.NextRunAt = next + // We broadcast to update the UI + s.svc.UpdateJob(ctx, jobID, service.UpdateJobRequest{}) //nolint:errcheck +} diff --git a/go/plugins/cron-mcp/internal/service/cron_service.go b/go/plugins/cron-mcp/internal/service/cron_service.go new file mode 100644 index 000000000..2023c53cf --- /dev/null +++ b/go/plugins/cron-mcp/internal/service/cron_service.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/db" + "gorm.io/gorm" +) + +// JobFilter defines filters for listing jobs. +type JobFilter struct { + Status *db.JobStatus + Label *string +} + +// CreateJobRequest holds the data for creating a new cron job. +type CreateJobRequest struct { + Name string + Description string + Schedule string + Command string + Labels []string + Timeout int + MaxRetries int +} + +// UpdateJobRequest holds fields for updating an existing cron job. +type UpdateJobRequest struct { + Name *string + Description *string + Schedule *string + Command *string + Status *db.JobStatus + Labels *[]string + Timeout *int + MaxRetries *int +} + +// Broadcaster is an interface for broadcasting job change events. +type Broadcaster interface { + Broadcast(event interface{}) +} + +// CronService provides CRUD operations for cron jobs. +type CronService struct { + db *gorm.DB + broadcaster Broadcaster +} + +// NewCronService creates a new CronService. +func NewCronService(db *gorm.DB, b Broadcaster) *CronService { + return &CronService{db: db, broadcaster: b} +} + +// ListJobs returns jobs matching the filter. +func (s *CronService) ListJobs(ctx context.Context, filter JobFilter) ([]*db.CronJob, error) { + q := s.db.WithContext(ctx) + + if filter.Status != nil { + q = q.Where("status = ?", *filter.Status) + } + + var jobs []*db.CronJob + if err := q.Order("id DESC").Find(&jobs).Error; err != nil { + return nil, fmt.Errorf("failed to list jobs: %w", err) + } + + if filter.Label != nil { + label := strings.ToLower(*filter.Label) + filtered := make([]*db.CronJob, 0) + for _, j := range jobs { + for _, l := range j.Labels { + if strings.ToLower(l) == label { + filtered = append(filtered, j) + break + } + } + } + jobs = filtered + } + + return jobs, nil +} + +// GetJob returns a job by ID with recent executions preloaded. +func (s *CronService) GetJob(ctx context.Context, id uint) (*db.CronJob, error) { + var job db.CronJob + if err := s.db.WithContext(ctx).Preload("Executions", func(tx *gorm.DB) *gorm.DB { + return tx.Order("id DESC").Limit(20) + }).First(&job, id).Error; err != nil { + return nil, fmt.Errorf("job %d not found: %w", id, err) + } + return &job, nil +} + +// CreateJob creates a new cron job. +func (s *CronService) CreateJob(ctx context.Context, req CreateJobRequest) (*db.CronJob, error) { + timeout := req.Timeout + if timeout <= 0 { + timeout = 300 + } + + job := &db.CronJob{ + Name: req.Name, + Description: req.Description, + Schedule: req.Schedule, + Command: req.Command, + Status: db.StatusActive, + Labels: deduplicateLabels(req.Labels), + Timeout: timeout, + MaxRetries: req.MaxRetries, + } + + if err := s.db.WithContext(ctx).Create(job).Error; err != nil { + return nil, fmt.Errorf("failed to create job: %w", err) + } + + s.broadcaster.Broadcast(job) + return job, nil +} + +// UpdateJob updates an existing cron job's fields. +func (s *CronService) UpdateJob(ctx context.Context, id uint, req UpdateJobRequest) (*db.CronJob, error) { + job, err := s.GetJob(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + job.Name = *req.Name + } + if req.Description != nil { + job.Description = *req.Description + } + if req.Schedule != nil { + job.Schedule = *req.Schedule + } + if req.Command != nil { + job.Command = *req.Command + } + if req.Status != nil { + if !db.ValidStatus(*req.Status) { + return nil, fmt.Errorf("invalid status %q: valid statuses are %v", *req.Status, db.StatusList) + } + job.Status = *req.Status + } + if req.Labels != nil { + job.Labels = deduplicateLabels(*req.Labels) + } + if req.Timeout != nil { + job.Timeout = *req.Timeout + } + if req.MaxRetries != nil { + job.MaxRetries = *req.MaxRetries + } + + if err := s.db.WithContext(ctx).Save(job).Error; err != nil { + return nil, fmt.Errorf("failed to update job %d: %w", id, err) + } + + s.broadcaster.Broadcast(job) + return job, nil +} + +// ToggleJob toggles a job between Active and Paused status. +func (s *CronService) ToggleJob(ctx context.Context, id uint) (*db.CronJob, error) { + job, err := s.GetJob(ctx, id) + if err != nil { + return nil, err + } + + if job.Status == db.StatusActive { + job.Status = db.StatusPaused + } else { + job.Status = db.StatusActive + } + + if err := s.db.WithContext(ctx).Save(job).Error; err != nil { + return nil, fmt.Errorf("failed to toggle job %d: %w", id, err) + } + + s.broadcaster.Broadcast(job) + return job, nil +} + +// DeleteJob deletes a job and all its executions. +func (s *CronService) DeleteJob(ctx context.Context, id uint) error { + if _, err := s.GetJob(ctx, id); err != nil { + return err + } + + if err := s.db.WithContext(ctx).Where("cron_job_id = ?", id).Delete(&db.Execution{}).Error; err != nil { + return fmt.Errorf("failed to delete executions of job %d: %w", id, err) + } + + if err := s.db.WithContext(ctx).Delete(&db.CronJob{}, id).Error; err != nil { + return fmt.Errorf("failed to delete job %d: %w", id, err) + } + + s.broadcaster.Broadcast(nil) + return nil +} + +// StartExecution records the start of a job execution. +func (s *CronService) StartExecution(ctx context.Context, jobID uint) (*db.Execution, error) { + exec := &db.Execution{ + CronJobID: jobID, + Status: db.ExecRunning, + StartedAt: time.Now(), + } + + if err := s.db.WithContext(ctx).Create(exec).Error; err != nil { + return nil, fmt.Errorf("failed to create execution: %w", err) + } + + now := time.Now() + s.db.WithContext(ctx).Model(&db.CronJob{}).Where("id = ?", jobID).Updates(map[string]interface{}{ + "last_run_at": now, + "last_run_status": db.ExecRunning, + }) + + s.broadcaster.Broadcast(exec) + return exec, nil +} + +// FinishExecution records the completion of a job execution. +func (s *CronService) FinishExecution(ctx context.Context, execID uint, output string, exitCode int) (*db.Execution, error) { + var exec db.Execution + if err := s.db.WithContext(ctx).First(&exec, execID).Error; err != nil { + return nil, fmt.Errorf("execution %d not found: %w", execID, err) + } + + now := time.Now() + duration := now.Sub(exec.StartedAt).Seconds() + status := db.ExecSuccess + if exitCode != 0 { + status = db.ExecFailed + } + + exec.Status = status + exec.Output = output + exec.ExitCode = &exitCode + exec.FinishedAt = &now + exec.Duration = &duration + + if err := s.db.WithContext(ctx).Save(&exec).Error; err != nil { + return nil, fmt.Errorf("failed to finish execution %d: %w", execID, err) + } + + s.db.WithContext(ctx).Model(&db.CronJob{}).Where("id = ?", exec.CronJobID).Update("last_run_status", status) + + s.broadcaster.Broadcast(&exec) + return &exec, nil +} + +// ListExecutions returns recent executions for a job. +func (s *CronService) ListExecutions(ctx context.Context, jobID uint, limit int) ([]*db.Execution, error) { + if limit <= 0 { + limit = 50 + } + var execs []*db.Execution + if err := s.db.WithContext(ctx).Where("cron_job_id = ?", jobID).Order("id DESC").Limit(limit).Find(&execs).Error; err != nil { + return nil, fmt.Errorf("failed to list executions: %w", err) + } + return execs, nil +} + +// GetExecution returns a single execution by ID. +func (s *CronService) GetExecution(ctx context.Context, id uint) (*db.Execution, error) { + var exec db.Execution + if err := s.db.WithContext(ctx).First(&exec, id).Error; err != nil { + return nil, fmt.Errorf("execution %d not found: %w", id, err) + } + return &exec, nil +} + +// GetAllJobs returns all jobs without filtering (for the board view). +func (s *CronService) GetAllJobs(ctx context.Context) ([]*db.CronJob, error) { + var jobs []*db.CronJob + if err := s.db.WithContext(ctx).Order("id DESC").Find(&jobs).Error; err != nil { + return nil, fmt.Errorf("failed to list all jobs: %w", err) + } + return jobs, nil +} + +func deduplicateLabels(labels []string) db.StringSlice { + if labels == nil { + return nil + } + seen := make(map[string]struct{}) + result := make(db.StringSlice, 0, len(labels)) + for _, l := range labels { + lower := strings.ToLower(l) + if _, ok := seen[lower]; !ok { + seen[lower] = struct{}{} + result = append(result, l) + } + } + return result +} diff --git a/go/plugins/cron-mcp/internal/sse/hub.go b/go/plugins/cron-mcp/internal/sse/hub.go new file mode 100644 index 000000000..3c508690d --- /dev/null +++ b/go/plugins/cron-mcp/internal/sse/hub.go @@ -0,0 +1,114 @@ +package sse + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// subBufferSize is the channel buffer per subscriber. +const subBufferSize = 16 + +// Event represents an SSE event sent to clients. +type Event struct { + Type string `json:"type"` + Data interface{} `json:"data"` +} + +// Hub manages SSE subscriber connections and broadcasts events to all of them. +// It implements service.Broadcaster. +type Hub struct { + mu sync.RWMutex + subs map[chan Event]struct{} + lastJSON []byte +} + +// NewHub creates an empty Hub. +func NewHub() *Hub { + return &Hub{ + subs: make(map[chan Event]struct{}), + } +} + +// Subscribe registers a new subscriber and returns a buffered channel for events. +func (h *Hub) Subscribe() chan Event { + ch := make(chan Event, subBufferSize) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +// Unsubscribe removes the given subscriber channel. +func (h *Hub) Unsubscribe(ch chan Event) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() +} + +// Broadcast wraps data in a job_update Event, stores it as the latest snapshot, +// and non-blockingly delivers it to all current subscribers. +func (h *Hub) Broadcast(data interface{}) { + event := Event{Type: "job_update", Data: data} + + eventJSON, err := json.Marshal(event) + + h.mu.Lock() + if err == nil { + h.lastJSON = eventJSON + } + clients := make([]chan Event, 0, len(h.subs)) + for ch := range h.subs { + clients = append(clients, ch) + } + h.mu.Unlock() + + for _, ch := range clients { + select { + case ch <- event: + default: + } + } +} + +// ServeSSE handles the /events SSE endpoint. +func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + ch := h.Subscribe() + defer h.Unsubscribe(ch) + + h.mu.RLock() + lastJSON := h.lastJSON + h.mu.RUnlock() + + if lastJSON != nil { + fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", lastJSON) + } else { + fmt.Fprintf(w, "event: snapshot\ndata: {}\n\n") + } + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case event := <-ch: + eventJSON, err := json.Marshal(event) + if err != nil { + continue + } + fmt.Fprintf(w, "data: %s\n\n", eventJSON) + flusher.Flush() + } + } +} diff --git a/go/plugins/cron-mcp/internal/ui/embed.go b/go/plugins/cron-mcp/internal/ui/embed.go new file mode 100644 index 000000000..8a41bef90 --- /dev/null +++ b/go/plugins/cron-mcp/internal/ui/embed.go @@ -0,0 +1,17 @@ +package ui + +import ( + _ "embed" + "net/http" +) + +//go:embed index.html +var indexHTML []byte + +// Handler returns an http.Handler that serves the embedded SPA. +func Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) //nolint:errcheck + }) +} diff --git a/go/plugins/cron-mcp/internal/ui/index.html b/go/plugins/cron-mcp/internal/ui/index.html new file mode 100644 index 000000000..54d0f4b41 --- /dev/null +++ b/go/plugins/cron-mcp/internal/ui/index.html @@ -0,0 +1,692 @@ + + + + + +Cron Jobs + + + +
+

Cron Jobs

+ connecting... +
+
+ + + + +
+
+
+
+ Loading... +
+
+ + + diff --git a/go/plugins/cron-mcp/main.go b/go/plugins/cron-mcp/main.go new file mode 100644 index 000000000..276c685d3 --- /dev/null +++ b/go/plugins/cron-mcp/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/db" + cronmcp "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/scheduler" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/sse" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + log.Printf("cron-mcp config: addr=%s transport=%s db-type=%s db-path=%s log-level=%s shell=%s", + cfg.Addr, cfg.Transport, cfg.DBType, cfg.DBPath, cfg.LogLevel, cfg.Shell) + + mgr, err := db.NewManager(cfg) + if err != nil { + log.Fatalf("failed to create database manager: %v", err) + } + if err := mgr.Initialize(); err != nil { + log.Fatalf("failed to initialize database: %v", err) + } + log.Printf("database initialized") + + hub := sse.NewHub() + svc := service.NewCronService(mgr.DB(), hub) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + // Create scheduler + sched := scheduler.New(svc, cfg.Shell) + if err := sched.Start(ctx); err != nil { + log.Fatalf("failed to start scheduler: %v", err) + } + defer sched.Stop() + + if cfg.Transport == "stdio" { + log.Printf("starting in stdio transport mode") + mcpServer := cronmcp.NewServer(svc, sched) + if err := mcpServer.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + log.Fatalf("MCP stdio server error: %v", err) + } + return + } + + // HTTP mode + srv := NewHTTPServer(cfg, svc, hub, sched) + log.Printf("cron-mcp listening on %s", cfg.Addr) + + go func() { + <-ctx.Done() + srv.Close() //nolint:errcheck + }() + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } +} diff --git a/go/plugins/cron-mcp/server.go b/go/plugins/cron-mcp/server.go new file mode 100644 index 000000000..3078eacb8 --- /dev/null +++ b/go/plugins/cron-mcp/server.go @@ -0,0 +1,36 @@ +package main + +import ( + "net/http" + + cronapi "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/api" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/config" + cronmcp "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/scheduler" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/cron-mcp/internal/ui" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NewHTTPServer constructs the HTTP server with all routes wired. +func NewHTTPServer(cfg *config.Config, svc *service.CronService, hub *sse.Hub, sched *scheduler.Scheduler) *http.Server { + mcpServer := cronmcp.NewServer(svc, sched) + mcpHandler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return mcpServer + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpHandler) + mux.HandleFunc("/events", hub.ServeSSE) + mux.HandleFunc("/api/jobs", cronapi.JobsHandler(svc, sched)) + mux.HandleFunc("/api/jobs/", cronapi.JobHandler(svc, sched)) + mux.HandleFunc("/api/executions/", cronapi.ExecutionHandler(svc)) + mux.HandleFunc("/api/board", cronapi.BoardHandler(svc)) + mux.Handle("/", ui.Handler()) + + return &http.Server{ + Addr: cfg.Addr, + Handler: mux, + } +} diff --git a/go/plugins/gitrepo-mcp/Dockerfile b/go/plugins/gitrepo-mcp/Dockerfile new file mode 100644 index 000000000..55cc97467 --- /dev/null +++ b/go/plugins/gitrepo-mcp/Dockerfile @@ -0,0 +1,43 @@ +### STAGE 1: build +ARG BASE_IMAGE_REGISTRY=cgr.dev +ARG BUILDPLATFORM +FROM --platform=$BUILDPLATFORM $BASE_IMAGE_REGISTRY/chainguard/go:latest AS builder +ARG TARGETARCH + +WORKDIR /workspace +COPY go.work . +COPY api/go.mod api/go.sum api/ +COPY core/go.mod core/go.sum core/ +COPY adk/go.mod adk/go.sum adk/ +COPY plugins/kanban-mcp/go.mod plugins/kanban-mcp/go.sum plugins/kanban-mcp/ +COPY plugins/gitrepo-mcp/go.mod plugins/gitrepo-mcp/go.sum plugins/gitrepo-mcp/ +COPY plugins/temporal-mcp/go.mod plugins/temporal-mcp/go.sum plugins/temporal-mcp/ +COPY plugins/nats-activity-feed/go.mod plugins/nats-activity-feed/go.sum plugins/nats-activity-feed/ +COPY plugins/cron-mcp/go.mod plugins/cron-mcp/go.sum plugins/cron-mcp/ +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + go work sync && go mod download +COPY api/ api/ +COPY core/ core/ +COPY adk/ adk/ +COPY plugins/ plugins/ + +ARG LDFLAGS +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + echo "Building on $BUILDPLATFORM -> linux/$TARGETARCH" && \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -ldflags "$LDFLAGS" -o /app "./plugins/gitrepo-mcp/" + +### STAGE 2: runtime with git +ARG BASE_IMAGE_REGISTRY=cgr.dev +FROM $BASE_IMAGE_REGISTRY/chainguard/wolfi-base:latest +RUN apk add --no-cache git ca-certificates +COPY --from=builder /app /app +USER 65532:65532 + +ARG VERSION +LABEL org.opencontainers.image.source=https://github.com/kagent-dev/kagent +LABEL org.opencontainers.image.description="Kagent gitrepo-mcp plugin" +LABEL org.opencontainers.image.version="$VERSION" + +ENTRYPOINT ["/app"] diff --git a/go/plugins/gitrepo-mcp/go.mod b/go/plugins/gitrepo-mcp/go.mod new file mode 100644 index 000000000..b79944aa7 --- /dev/null +++ b/go/plugins/gitrepo-mcp/go.mod @@ -0,0 +1,47 @@ +module github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp + +go 1.25.7 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 + github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.10.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.26.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.20.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go/plugins/gitrepo-mcp/go.sum b/go/plugins/gitrepo-mcp/go.sum new file mode 100644 index 000000000..46c419fb3 --- /dev/null +++ b/go/plugins/gitrepo-mcp/go.sum @@ -0,0 +1,99 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= +gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/go/plugins/gitrepo-mcp/internal/config/config.go b/go/plugins/gitrepo-mcp/internal/config/config.go new file mode 100644 index 000000000..a7ba56b2e --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "flag" + "os" +) + +// DBType represents the database backend type. +type DBType string + +const ( + DBTypeSQLite DBType = "sqlite" + DBTypePostgres DBType = "postgres" +) + +// Config holds all runtime settings for the gitrepo-mcp server. +type Config struct { + Addr string // --addr / GITREPO_ADDR, default ":8090" + Transport string // --transport / GITREPO_TRANSPORT, "http" | "stdio" + DBType DBType // --db-type / GITREPO_DB_TYPE, "sqlite" | "postgres" + DBPath string // --db-path / GITREPO_DB_PATH, default "./data/gitrepo.db" + DBURL string // --db-url / GITREPO_DB_URL + DataDir string // --data-dir / GITREPO_DATA_DIR, default "./data" + LogLevel string // --log-level / GITREPO_LOG_LEVEL, default "info" +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// Load parses CLI flags (os.Args[1:]) with GITREPO_* environment variable fallback. +func Load() (*Config, error) { + return LoadArgs(os.Args[1:]) +} + +// LoadArgs parses the given args with GITREPO_* environment variable fallback. +func LoadArgs(args []string) (*Config, error) { + fs := flag.NewFlagSet("gitrepo-mcp", flag.ContinueOnError) + + addr := fs.String("addr", envOrDefault("GITREPO_ADDR", ":8090"), "listen address") + transport := fs.String("transport", envOrDefault("GITREPO_TRANSPORT", "http"), "transport mode: http or stdio") + dbType := fs.String("db-type", envOrDefault("GITREPO_DB_TYPE", "sqlite"), "database type: sqlite or postgres") + dbPath := fs.String("db-path", envOrDefault("GITREPO_DB_PATH", "./data/gitrepo.db"), "SQLite database file path") + dbURL := fs.String("db-url", envOrDefault("GITREPO_DB_URL", ""), "Postgres connection URL") + dataDir := fs.String("data-dir", envOrDefault("GITREPO_DATA_DIR", "./data"), "data directory for cloned repos and database") + logLevel := fs.String("log-level", envOrDefault("GITREPO_LOG_LEVEL", "info"), "log level: debug, info, warn, error") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + return &Config{ + Addr: *addr, + Transport: *transport, + DBType: DBType(*dbType), + DBPath: *dbPath, + DBURL: *dbURL, + DataDir: *dataDir, + LogLevel: *logLevel, + }, nil +} diff --git a/go/plugins/gitrepo-mcp/internal/config/config_test.go b/go/plugins/gitrepo-mcp/internal/config/config_test.go new file mode 100644 index 000000000..9e93f1664 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/config/config_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadArgs_Defaults(t *testing.T) { + cfg, err := LoadArgs([]string{}) + require.NoError(t, err) + + assert.Equal(t, ":8090", cfg.Addr) + assert.Equal(t, "http", cfg.Transport) + assert.Equal(t, DBTypeSQLite, cfg.DBType) + assert.Equal(t, "./data/gitrepo.db", cfg.DBPath) + assert.Equal(t, "", cfg.DBURL) + assert.Equal(t, "./data", cfg.DataDir) + assert.Equal(t, "info", cfg.LogLevel) +} + +func TestLoadArgs_CustomFlags(t *testing.T) { + cfg, err := LoadArgs([]string{ + "--addr", ":9090", + "--transport", "stdio", + "--db-type", "postgres", + "--db-url", "postgres://localhost:5432/test", + "--data-dir", "/custom/data", + "--log-level", "debug", + }) + require.NoError(t, err) + + assert.Equal(t, ":9090", cfg.Addr) + assert.Equal(t, "stdio", cfg.Transport) + assert.Equal(t, DBTypePostgres, cfg.DBType) + assert.Equal(t, "postgres://localhost:5432/test", cfg.DBURL) + assert.Equal(t, "/custom/data", cfg.DataDir) + assert.Equal(t, "debug", cfg.LogLevel) +} + +func TestLoadArgs_EnvVarFallback(t *testing.T) { + t.Setenv("GITREPO_ADDR", ":7070") + t.Setenv("GITREPO_DATA_DIR", "/env/data") + + cfg, err := LoadArgs([]string{}) + require.NoError(t, err) + + assert.Equal(t, ":7070", cfg.Addr) + assert.Equal(t, "/env/data", cfg.DataDir) +} + +func TestLoadArgs_FlagsOverrideEnv(t *testing.T) { + t.Setenv("GITREPO_ADDR", ":7070") + + cfg, err := LoadArgs([]string{"--addr", ":9090"}) + require.NoError(t, err) + + assert.Equal(t, ":9090", cfg.Addr) +} diff --git a/go/plugins/gitrepo-mcp/internal/embedder/embedder.go b/go/plugins/gitrepo-mcp/internal/embedder/embedder.go new file mode 100644 index 000000000..bda47d074 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/embedder/embedder.go @@ -0,0 +1,13 @@ +package embedder + +// EmbeddingModel generates vector embeddings for text. +type EmbeddingModel interface { + // EmbedBatch embeds a batch of texts and returns one vector per text. + EmbedBatch(texts []string) ([][]float32, error) + + // Dimensions returns the dimensionality of the embedding vectors. + Dimensions() int + + // ModelName returns a human-readable model identifier. + ModelName() string +} diff --git a/go/plugins/gitrepo-mcp/internal/embedder/embedder_test.go b/go/plugins/gitrepo-mcp/internal/embedder/embedder_test.go new file mode 100644 index 000000000..3e65434cc --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/embedder/embedder_test.go @@ -0,0 +1,109 @@ +package embedder + +import ( + "math" + "testing" +) + +func TestHashEmbedder_Interface(t *testing.T) { + var _ EmbeddingModel = (*HashEmbedder)(nil) +} + +func TestHashEmbedder_ModelName(t *testing.T) { + e := NewHashEmbedder(768) + if e.ModelName() != "hash-embedder" { + t.Errorf("ModelName() = %q, want %q", e.ModelName(), "hash-embedder") + } +} + +func TestHashEmbedder_Dimensions(t *testing.T) { + e := NewHashEmbedder(768) + if e.Dimensions() != 768 { + t.Errorf("Dimensions() = %d, want %d", e.Dimensions(), 768) + } +} + +func TestHashEmbedder_EmbedBatch(t *testing.T) { + e := NewHashEmbedder(384) + texts := []string{"hello world", "func main()", "class Foo"} + + vecs, err := e.EmbedBatch(texts) + if err != nil { + t.Fatalf("EmbedBatch() error: %v", err) + } + if len(vecs) != 3 { + t.Fatalf("EmbedBatch() returned %d vectors, want 3", len(vecs)) + } + for i, vec := range vecs { + if len(vec) != 384 { + t.Errorf("vector[%d] length = %d, want 384", i, len(vec)) + } + } +} + +func TestHashEmbedder_Deterministic(t *testing.T) { + e := NewHashEmbedder(128) + text := "func Add(a, b int) int { return a + b }" + + v1, _ := e.EmbedBatch([]string{text}) + v2, _ := e.EmbedBatch([]string{text}) + + for i := range v1[0] { + if v1[0][i] != v2[0][i] { + t.Fatalf("not deterministic at index %d: %f != %f", i, v1[0][i], v2[0][i]) + } + } +} + +func TestHashEmbedder_DifferentInputsDifferentVectors(t *testing.T) { + e := NewHashEmbedder(128) + vecs, _ := e.EmbedBatch([]string{"hello", "world"}) + + same := true + for i := range vecs[0] { + if vecs[0][i] != vecs[1][i] { + same = false + break + } + } + if same { + t.Error("different inputs produced identical vectors") + } +} + +func TestHashEmbedder_UnitVector(t *testing.T) { + e := NewHashEmbedder(768) + vecs, _ := e.EmbedBatch([]string{"test normalization"}) + + var norm float64 + for _, v := range vecs[0] { + norm += float64(v) * float64(v) + } + norm = math.Sqrt(norm) + + if math.Abs(norm-1.0) > 1e-5 { + t.Errorf("L2 norm = %f, want ~1.0", norm) + } +} + +func TestHashEmbedder_EmptyBatch(t *testing.T) { + e := NewHashEmbedder(64) + vecs, err := e.EmbedBatch(nil) + if err != nil { + t.Fatalf("EmbedBatch(nil) error: %v", err) + } + if len(vecs) != 0 { + t.Errorf("EmbedBatch(nil) returned %d vectors, want 0", len(vecs)) + } +} + +func TestHashEmbedder_EmptyString(t *testing.T) { + e := NewHashEmbedder(64) + vecs, err := e.EmbedBatch([]string{""}) + if err != nil { + t.Fatalf("EmbedBatch error: %v", err) + } + if len(vecs) != 1 || len(vecs[0]) != 64 { + t.Errorf("unexpected result for empty string") + } +} diff --git a/go/plugins/gitrepo-mcp/internal/embedder/hash.go b/go/plugins/gitrepo-mcp/internal/embedder/hash.go new file mode 100644 index 000000000..659e7f163 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/embedder/hash.go @@ -0,0 +1,68 @@ +package embedder + +import ( + "crypto/sha256" + "encoding/binary" + "math" +) + +// HashEmbedder generates deterministic embeddings from content hashes. +// It produces consistent vectors: same text always yields the same embedding. +// Useful for development, testing, and as a fallback when ONNX is unavailable. +type HashEmbedder struct { + dims int +} + +// NewHashEmbedder creates a HashEmbedder with the given dimensionality. +func NewHashEmbedder(dims int) *HashEmbedder { + return &HashEmbedder{dims: dims} +} + +func (h *HashEmbedder) ModelName() string { return "hash-embedder" } +func (h *HashEmbedder) Dimensions() int { return h.dims } + +// EmbedBatch generates one embedding per input text. +func (h *HashEmbedder) EmbedBatch(texts []string) ([][]float32, error) { + result := make([][]float32, len(texts)) + for i, text := range texts { + result[i] = h.embed(text) + } + return result, nil +} + +// embed generates a deterministic unit vector from text content. +// Uses SHA256 as a seed for a simple PRNG to fill dimensions, then L2-normalizes. +func (h *HashEmbedder) embed(text string) []float32 { + vec := make([]float32, h.dims) + seed := sha256.Sum256([]byte(text)) + + // Use the 32-byte hash to seed a simple xorshift PRNG + var state uint64 + state = binary.LittleEndian.Uint64(seed[:8]) + if state == 0 { + state = 1 + } + + for i := range vec { + // xorshift64 + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + // Map to [-1, 1] range + vec[i] = float32(int64(state)) / float32(math.MaxInt64) + } + + // L2-normalize to unit vector + var norm float32 + for _, v := range vec { + norm += v * v + } + if norm > 0 { + invNorm := float32(1.0 / math.Sqrt(float64(norm))) + for i := range vec { + vec[i] *= invNorm + } + } + + return vec +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/chunk.go b/go/plugins/gitrepo-mcp/internal/indexer/chunk.go new file mode 100644 index 000000000..48a36eb4d --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/chunk.go @@ -0,0 +1,66 @@ +package indexer + +import ( + "bytes" + "path/filepath" +) + +// Chunk represents a code chunk extracted from a source file. +type Chunk struct { + FilePath string // relative path within the repo + LineStart int // 1-indexed + LineEnd int // 1-indexed, inclusive + ChunkType string // "function", "method", "class", "type", "interface", "impl", "struct", "heading", "document" + ChunkName string // identifier name (function name, class name, heading text, etc.) + Content string +} + +// ChunkFile parses a source file and returns structural code chunks. +// Language is detected from the file extension. +func ChunkFile(filePath string, content []byte) ([]Chunk, error) { + if len(bytes.TrimSpace(content)) == 0 { + return nil, nil + } + + lang := DetectLanguage(filePath) + + switch lang { + case "markdown": + return chunkMarkdown(filePath, content), nil + case "yaml", "toml", "groovy": + return chunkWholeFile(filePath, content, "document"), nil + case "": + return chunkWholeFile(filePath, content, "document"), nil + default: + chunks, err := chunkWithTreeSitter(filePath, content, lang) + if err != nil || len(chunks) == 0 { + return chunkWholeFile(filePath, content, "document"), nil + } + return chunks, nil + } +} + +func chunkWholeFile(filePath string, content []byte, chunkType string) []Chunk { + if len(content) == 0 { + return nil + } + return []Chunk{{ + FilePath: filePath, + LineStart: 1, + LineEnd: countLines(content), + ChunkType: chunkType, + ChunkName: filepath.Base(filePath), + Content: string(content), + }} +} + +func countLines(data []byte) int { + if len(data) == 0 { + return 0 + } + count := bytes.Count(data, []byte{'\n'}) + if data[len(data)-1] != '\n' { + count++ + } + return count +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/chunker_test.go b/go/plugins/gitrepo-mcp/internal/indexer/chunker_test.go new file mode 100644 index 000000000..2d0c45d31 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/chunker_test.go @@ -0,0 +1,252 @@ +package indexer + +import ( + "testing" +) + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"main.go", "go"}, + {"app.py", "python"}, + {"index.js", "javascript"}, + {"index.jsx", "javascript"}, + {"app.ts", "typescript"}, + {"app.tsx", "typescript"}, + {"Main.java", "java"}, + {"lib.rs", "rust"}, + {"README.md", "markdown"}, + {"doc.mdx", "markdown"}, + {"config.yaml", "yaml"}, + {"config.yml", "yaml"}, + {"pyproject.toml", "toml"}, + {"build.groovy", "groovy"}, + {"build.gradle", "groovy"}, + {"unknown.xyz", ""}, + {"Makefile", ""}, + {"Dockerfile", ""}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := DetectLanguage(tt.path) + if got != tt.want { + t.Errorf("DetectLanguage(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestChunkMarkdownFile(t *testing.T) { + source := `# Project Title + +Introduction text here. + +## Getting Started + +Setup instructions. + +## API Reference + +API documentation. + +### Endpoints + +Endpoint details. +` + chunks, err := ChunkFile("README.md", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) < 3 { + t.Fatalf("expected at least 3 chunks, got %d", len(chunks)) + } + + for _, c := range chunks { + if c.ChunkType != "heading" { + t.Errorf("chunk %q has type %q, want heading", c.ChunkName, c.ChunkType) + } + } + + found := findChunkByName(chunks, "Project Title") + if found == nil { + t.Fatal("expected chunk for Project Title") + } +} + +func TestChunkMarkdownNoHeadings(t *testing.T) { + source := `Just some text without any headings. + +Multiple paragraphs. +` + chunks, err := ChunkFile("notes.md", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 1 { + t.Fatalf("expected 1 whole-file chunk, got %d", len(chunks)) + } + if chunks[0].ChunkType != "document" { + t.Errorf("chunk type = %q, want document", chunks[0].ChunkType) + } +} + +func TestChunkYAMLFile(t *testing.T) { + source := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: value +` + chunks, err := ChunkFile("config.yaml", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + if chunks[0].ChunkType != "document" { + t.Errorf("chunk type = %q, want document", chunks[0].ChunkType) + } + if chunks[0].ChunkName != "config.yaml" { + t.Errorf("chunk name = %q, want config.yaml", chunks[0].ChunkName) + } + if chunks[0].LineStart != 1 { + t.Errorf("LineStart = %d, want 1", chunks[0].LineStart) + } +} + +func TestChunkTOMLFile(t *testing.T) { + source := `[package] +name = "my-project" +version = "0.1.0" +` + chunks, err := ChunkFile("pyproject.toml", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + if chunks[0].ChunkType != "document" { + t.Errorf("chunk type = %q, want document", chunks[0].ChunkType) + } +} + +func TestChunkGroovyFile(t *testing.T) { + source := `pipeline { + agent any + stages { + stage('Build') { + steps { sh 'make' } + } + } +} +` + chunks, err := ChunkFile("Jenkinsfile.groovy", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 1 { + t.Fatalf("expected 1 whole-file chunk for groovy, got %d", len(chunks)) + } + if chunks[0].ChunkType != "document" { + t.Errorf("chunk type = %q, want document", chunks[0].ChunkType) + } +} + +func TestChunkUnknownExtension(t *testing.T) { + source := `some random binary-ish content` + + chunks, err := ChunkFile("data.bin", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + if chunks[0].ChunkType != "document" { + t.Errorf("chunk type = %q, want document", chunks[0].ChunkType) + } +} + +func TestChunkEmptyFile(t *testing.T) { + chunks, err := ChunkFile("empty.go", []byte("")) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + if len(chunks) != 0 { + t.Errorf("expected 0 chunks for empty file, got %d", len(chunks)) + } +} + +func TestChunkWhitespaceOnly(t *testing.T) { + chunks, err := ChunkFile("blank.go", []byte(" \n \n ")) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + if len(chunks) != 0 { + t.Errorf("expected 0 chunks for whitespace-only file, got %d", len(chunks)) + } +} + +func TestCountLines(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty", "", 0}, + {"single line no newline", "hello", 1}, + {"single line with newline", "hello\n", 1}, + {"two lines", "hello\nworld", 2}, + {"two lines with trailing", "hello\nworld\n", 2}, + {"three lines", "a\nb\nc\n", 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countLines([]byte(tt.input)) + if got != tt.want { + t.Errorf("countLines(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +// TestChunkCodeFallback verifies that code files produce chunks even without CGo. +// With CGo: tree-sitter extracts structural chunks. +// Without CGo: falls back to whole-file document chunk. +func TestChunkCodeFallback(t *testing.T) { + source := `package main + +func hello() {} +` + chunks, err := ChunkFile("main.go", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + if len(chunks) == 0 { + t.Fatal("expected at least 1 chunk for a Go file") + } +} + +// helpers + +func findChunkByName(chunks []Chunk, name string) *Chunk { + for i := range chunks { + if chunks[i].ChunkName == name { + return &chunks[i] + } + } + return nil +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/indexer.go b/go/plugins/gitrepo-mcp/internal/indexer/indexer.go new file mode 100644 index 000000000..c18b18857 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/indexer.go @@ -0,0 +1,241 @@ +package indexer + +import ( + "crypto/sha256" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +const ( + defaultBatchSize = 32 + maxFileSize = 1 << 20 // 1 MB +) + +// Indexer orchestrates file walking, chunking, embedding, and storage. +type Indexer struct { + repoStore *storage.RepoStore + embeddingStore *storage.EmbeddingStore + embedder embedder.EmbeddingModel + batchSize int +} + +// NewIndexer creates an Indexer. +func NewIndexer( + repoStore *storage.RepoStore, + embeddingStore *storage.EmbeddingStore, + emb embedder.EmbeddingModel, +) *Indexer { + return &Indexer{ + repoStore: repoStore, + embeddingStore: embeddingStore, + embedder: emb, + batchSize: defaultBatchSize, + } +} + +// SetBatchSize overrides the default embedding batch size. +func (idx *Indexer) SetBatchSize(n int) { + if n > 0 { + idx.batchSize = n + } +} + +// Index indexes all supported files in a repository: +// walk files → chunk → content-hash dedup → batch embed → store. +func (idx *Indexer) Index(repoName string) error { + repo, err := idx.repoStore.Get(repoName) + if err != nil { + return fmt.Errorf("repo %s not found: %w", repoName, err) + } + + if repo.Status == storage.RepoStatusCloning || repo.Status == storage.RepoStatusIndexing { + return fmt.Errorf("repo %s is busy (status: %s)", repoName, repo.Status) + } + + // Set status to indexing + repo.Status = storage.RepoStatusIndexing + repo.Error = nil + if err := idx.repoStore.Update(repo); err != nil { + return fmt.Errorf("failed to update repo status: %w", err) + } + + // Run indexing, capture any error to set error status + fileCount, chunkCount, indexErr := idx.doIndex(repo) + + now := time.Now() + if indexErr != nil { + errMsg := indexErr.Error() + repo.Status = storage.RepoStatusError + repo.Error = &errMsg + _ = idx.repoStore.Update(repo) + return indexErr + } + + repo.Status = storage.RepoStatusIndexed + repo.LastIndexed = &now + repo.FileCount = fileCount + repo.ChunkCount = chunkCount + repo.Error = nil + if err := idx.repoStore.Update(repo); err != nil { + return fmt.Errorf("failed to update repo after indexing: %w", err) + } + + return nil +} + +func (idx *Indexer) doIndex(repo *storage.Repo) (fileCount, chunkCount int, err error) { + coll, err := idx.embeddingStore.GetOrCreateCollection( + repo.Name, + idx.embedder.ModelName(), + idx.embedder.Dimensions(), + ) + if err != nil { + return 0, 0, fmt.Errorf("failed to get/create collection: %w", err) + } + + // Delete existing chunks for a clean re-index + if err := idx.embeddingStore.DeleteChunksByCollection(coll.ID); err != nil { + return 0, 0, fmt.Errorf("failed to clear old chunks: %w", err) + } + + // Walk and chunk all supported files + type pendingChunk struct { + chunk Chunk + contentHash string + } + var pending []pendingChunk + + walkErr := filepath.WalkDir(repo.LocalPath, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + // Skip hidden directories + if d.IsDir() { + name := d.Name() + if strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + if name == "node_modules" || name == "vendor" || name == "__pycache__" { + return filepath.SkipDir + } + return nil + } + + // Skip non-regular files + if !d.Type().IsRegular() { + return nil + } + + // Skip files with no known language + if DetectLanguage(path) == "" { + return nil + } + + // Skip large files + info, infoErr := d.Info() + if infoErr != nil { + return nil + } + if info.Size() > maxFileSize { + return nil + } + + content, readErr := os.ReadFile(path) + if readErr != nil { + log.Printf("warn: skip unreadable file %s: %v", path, readErr) + return nil + } + + relPath, _ := filepath.Rel(repo.LocalPath, path) + + chunks, chunkErr := ChunkFile(relPath, content) + if chunkErr != nil { + log.Printf("warn: skip unchunkable file %s: %v", relPath, chunkErr) + return nil + } + + fileCount++ + for _, c := range chunks { + hash := contentHash(c.Content) + pending = append(pending, pendingChunk{chunk: c, contentHash: hash}) + } + + return nil + }) + if walkErr != nil { + return 0, 0, fmt.Errorf("failed to walk repo directory: %w", walkErr) + } + + if len(pending) == 0 { + return fileCount, 0, nil + } + + // Batch embed and store + for batchStart := 0; batchStart < len(pending); batchStart += idx.batchSize { + batchEnd := batchStart + idx.batchSize + if batchEnd > len(pending) { + batchEnd = len(pending) + } + batch := pending[batchStart:batchEnd] + + // Collect texts for embedding + texts := make([]string, len(batch)) + for i, p := range batch { + texts[i] = p.chunk.Content + } + + vectors, embedErr := idx.embedder.EmbedBatch(texts) + if embedErr != nil { + return 0, 0, fmt.Errorf("embedding batch failed: %w", embedErr) + } + + // Build storage chunks + storageChunks := make([]storage.Chunk, len(batch)) + for i, p := range batch { + var name *string + if p.chunk.ChunkName != "" { + n := p.chunk.ChunkName + name = &n + } + storageChunks[i] = storage.Chunk{ + CollectionID: coll.ID, + FilePath: p.chunk.FilePath, + LineStart: p.chunk.LineStart, + LineEnd: p.chunk.LineEnd, + ChunkType: p.chunk.ChunkType, + ChunkName: name, + Content: p.chunk.Content, + ContentHash: p.contentHash, + Embedding: storage.EncodeEmbedding(vectors[i]), + } + } + + if err := idx.embeddingStore.InsertChunks(storageChunks); err != nil { + return 0, 0, fmt.Errorf("failed to insert chunk batch: %w", err) + } + + chunkCount += len(storageChunks) + } + + return fileCount, chunkCount, nil +} + +// contentHash returns the SHA256 hex digest of a string. +func contentHash(s string) string { + h := sha256.Sum256([]byte(s)) + return fmt.Sprintf("%x", h) +} + +// IsSupportedFile returns true if the file path has a known language extension. +func IsSupportedFile(filePath string) bool { + return DetectLanguage(filePath) != "" +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/indexer_test.go b/go/plugins/gitrepo-mcp/internal/indexer/indexer_test.go new file mode 100644 index 000000000..f239e927a --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/indexer_test.go @@ -0,0 +1,459 @@ +package indexer + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +func setupTestDB(t *testing.T) (*storage.RepoStore, *storage.EmbeddingStore) { + t.Helper() + tmpDir := t.TempDir() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: filepath.Join(tmpDir, "test.db"), + } + mgr, err := storage.NewManager(cfg) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + return storage.NewRepoStore(mgr.DB()), storage.NewEmbeddingStore(mgr.DB()) +} + +func createFakeRepo(t *testing.T, repoStore *storage.RepoStore, name string) string { + t.Helper() + repoDir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(repoDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + repo := &storage.Repo{ + Name: name, + URL: "https://example.com/" + name, + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: repoDir, + } + if err := repoStore.Create(repo); err != nil { + t.Fatalf("Create repo: %v", err) + } + return repoDir +} + +func writeFile(t *testing.T, dir, relPath, content string) { + t.Helper() + full := filepath.Join(dir, relPath) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func TestIndexer_IndexEmptyRepo(t *testing.T) { + repoStore, embStore := setupTestDB(t) + _ = createFakeRepo(t, repoStore, "empty") + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("empty"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("empty") + if repo.Status != storage.RepoStatusIndexed { + t.Errorf("status = %s, want indexed", repo.Status) + } + if repo.FileCount != 0 { + t.Errorf("fileCount = %d, want 0", repo.FileCount) + } + if repo.ChunkCount != 0 { + t.Errorf("chunkCount = %d, want 0", repo.ChunkCount) + } +} + +func TestIndexer_IndexGoFile(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "gotest") + + writeFile(t, repoDir, "main.go", `package main + +func Hello() string { + return "hello" +} + +func World() string { + return "world" +} +`) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("gotest"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("gotest") + if repo.Status != storage.RepoStatusIndexed { + t.Errorf("status = %s, want indexed", repo.Status) + } + if repo.FileCount != 1 { + t.Errorf("fileCount = %d, want 1", repo.FileCount) + } + if repo.ChunkCount < 1 { + t.Errorf("chunkCount = %d, want >= 1", repo.ChunkCount) + } + if repo.LastIndexed == nil { + t.Error("lastIndexed should be set") + } +} + +func TestIndexer_IndexMultipleFiles(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "multi") + + writeFile(t, repoDir, "main.go", `package main +func Main() {} +`) + writeFile(t, repoDir, "README.md", `# My Project +## Overview +Some text here. +`) + writeFile(t, repoDir, "config.yaml", `key: value +`) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("multi"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("multi") + if repo.FileCount != 3 { + t.Errorf("fileCount = %d, want 3", repo.FileCount) + } + if repo.ChunkCount < 3 { + t.Errorf("chunkCount = %d, want >= 3", repo.ChunkCount) + } +} + +func TestIndexer_SkipsHiddenDirs(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "hidden") + + writeFile(t, repoDir, "visible.go", `package main +func Visible() {} +`) + writeFile(t, repoDir, ".git/config", `[core] +`) + writeFile(t, repoDir, ".hidden/secret.go", `package hidden +func Secret() {} +`) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("hidden"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("hidden") + if repo.FileCount != 1 { + t.Errorf("fileCount = %d, want 1 (only visible.go)", repo.FileCount) + } +} + +func TestIndexer_SkipsNodeModules(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "skipnm") + + writeFile(t, repoDir, "app.js", `function hello() { return "hi"; } +`) + writeFile(t, repoDir, "node_modules/lodash/index.js", `module.exports = {}; +`) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("skipnm"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("skipnm") + if repo.FileCount != 1 { + t.Errorf("fileCount = %d, want 1", repo.FileCount) + } +} + +func TestIndexer_SkipsUnsupportedExtensions(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "unsupported") + + writeFile(t, repoDir, "main.go", `package main +func Main() {} +`) + writeFile(t, repoDir, "image.png", "fake png data") + writeFile(t, repoDir, "data.csv", "a,b,c\n1,2,3\n") + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("unsupported"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("unsupported") + if repo.FileCount != 1 { + t.Errorf("fileCount = %d, want 1 (only main.go)", repo.FileCount) + } +} + +func TestIndexer_RejectsCloning(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "busy") + + // Set to cloning status + repo, _ := repoStore.Get("busy") + repo.Status = storage.RepoStatusCloning + _ = repoStore.Update(repo) + _ = repoDir + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + err := idx.Index("busy") + if err == nil { + t.Fatal("expected error for busy repo") + } +} + +func TestIndexer_RejectsAlreadyIndexing(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "indexing") + + repo, _ := repoStore.Get("indexing") + repo.Status = storage.RepoStatusIndexing + _ = repoStore.Update(repo) + _ = repoDir + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + err := idx.Index("indexing") + if err == nil { + t.Fatal("expected error for already-indexing repo") + } +} + +func TestIndexer_NotFound(t *testing.T) { + repoStore, embStore := setupTestDB(t) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + err := idx.Index("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent repo") + } +} + +func TestIndexer_BatchProcessing(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "batch") + + // Create enough files to trigger multiple batches + for i := 0; i < 5; i++ { + name := fmt.Sprintf("file%d.go", i) + content := fmt.Sprintf("package main\nfunc F%d() {}\n", i) + writeFile(t, repoDir, name, content) + } + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + idx.SetBatchSize(2) // small batch to test batching + + if err := idx.Index("batch"); err != nil { + t.Fatalf("Index: %v", err) + } + + repo, _ := repoStore.Get("batch") + if repo.FileCount != 5 { + t.Errorf("fileCount = %d, want 5", repo.FileCount) + } + if repo.ChunkCount < 5 { + t.Errorf("chunkCount = %d, want >= 5", repo.ChunkCount) + } +} + +func TestIndexer_EmbeddingsStored(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "embcheck") + + writeFile(t, repoDir, "main.go", `package main +func Hello() {} +`) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + if err := idx.Index("embcheck"); err != nil { + t.Fatalf("Index: %v", err) + } + + // Get collection + coll, err := embStore.GetOrCreateCollection("embcheck", "hash-embedder", 128) + if err != nil { + t.Fatalf("GetOrCreateCollection: %v", err) + } + + chunks, err := embStore.GetChunksByCollection(coll.ID) + if err != nil { + t.Fatalf("GetChunksByCollection: %v", err) + } + + if len(chunks) < 1 { + t.Fatal("expected at least 1 chunk") + } + + for _, c := range chunks { + if len(c.Embedding) == 0 { + t.Errorf("chunk %q has empty embedding", c.FilePath) + } + vec := storage.DecodeEmbedding(c.Embedding) + if len(vec) != 128 { + t.Errorf("decoded embedding length = %d, want 128", len(vec)) + } + if c.ContentHash == "" { + t.Errorf("chunk %q has empty content hash", c.FilePath) + } + } +} + +func TestIndexer_ReindexClearsOldChunks(t *testing.T) { + repoStore, embStore := setupTestDB(t) + repoDir := createFakeRepo(t, repoStore, "reindex") + + writeFile(t, repoDir, "main.go", `package main +func Hello() {} +`) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + // First index + if err := idx.Index("reindex"); err != nil { + t.Fatalf("first Index: %v", err) + } + + repo, _ := repoStore.Get("reindex") + firstCount := repo.ChunkCount + + // Reset status to allow re-index + repo.Status = storage.RepoStatusCloned + _ = repoStore.Update(repo) + + // Add another file + writeFile(t, repoDir, "util.go", `package main +func Util() {} +`) + + // Second index + if err := idx.Index("reindex"); err != nil { + t.Fatalf("second Index: %v", err) + } + + repo, _ = repoStore.Get("reindex") + if repo.ChunkCount <= firstCount { + t.Errorf("re-index should produce more chunks: got %d, first was %d", repo.ChunkCount, firstCount) + } + + // Verify old chunks were replaced (not duplicated) + coll, _ := embStore.GetOrCreateCollection("reindex", "hash-embedder", 128) + chunks, _ := embStore.GetChunksByCollection(coll.ID) + if len(chunks) != repo.ChunkCount { + t.Errorf("actual chunks in DB = %d, repo says %d", len(chunks), repo.ChunkCount) + } +} + +func TestIndexer_ErrorSetsStatus(t *testing.T) { + repoStore, embStore := setupTestDB(t) + + // Create repo with non-existent local path + repo := &storage.Repo{ + Name: "badpath", + URL: "https://example.com/bad", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: "/nonexistent/path/that/does/not/exist", + } + _ = repoStore.Create(repo) + + emb := embedder.NewHashEmbedder(128) + idx := NewIndexer(repoStore, embStore, emb) + + err := idx.Index("badpath") + if err == nil { + t.Fatal("expected error for bad path") + } + + repo, _ = repoStore.Get("badpath") + if repo.Status != storage.RepoStatusError { + t.Errorf("status = %s, want error", repo.Status) + } + if repo.Error == nil { + t.Error("error message should be set") + } +} + +func TestContentHash(t *testing.T) { + h1 := contentHash("hello") + h2 := contentHash("hello") + h3 := contentHash("world") + + if h1 != h2 { + t.Error("same input should produce same hash") + } + if h1 == h3 { + t.Error("different inputs should produce different hashes") + } + + // Verify it's a valid SHA256 hex string + expected := fmt.Sprintf("%x", sha256.Sum256([]byte("hello"))) + if h1 != expected { + t.Errorf("contentHash = %q, want %q", h1, expected) + } +} + +func TestIsSupportedFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"main.go", true}, + {"app.py", true}, + {"index.ts", true}, + {"README.md", true}, + {"config.yaml", true}, + {"image.png", false}, + {"data.csv", false}, + {"Makefile", false}, + } + for _, tt := range tests { + if got := IsSupportedFile(tt.path); got != tt.want { + t.Errorf("IsSupportedFile(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/languages.go b/go/plugins/gitrepo-mcp/internal/indexer/languages.go new file mode 100644 index 000000000..1520ad544 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/languages.go @@ -0,0 +1,43 @@ +package indexer + +import ( + "path/filepath" + "strings" +) + +var extToLang = map[string]string{ + ".go": "go", + ".py": "python", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".java": "java", + ".rs": "rust", + ".md": "markdown", + ".mdx": "markdown", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".groovy": "groovy", + ".gradle": "groovy", + ".jenkinsfile": "groovy", +} + +// DetectLanguage returns the language identifier for a file path based on extension. +// Returns empty string for unknown extensions. +func DetectLanguage(filePath string) string { + ext := strings.ToLower(filepath.Ext(filePath)) + if lang, ok := extToLang[ext]; ok { + return lang + } + base := strings.ToLower(filepath.Base(filePath)) + switch base { + case "jenkinsfile", "groovyfile": + return "groovy" + case "makefile", "dockerfile": + return "" + } + return "" +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/markdown.go b/go/plugins/gitrepo-mcp/internal/indexer/markdown.go new file mode 100644 index 000000000..63f9387a2 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/markdown.go @@ -0,0 +1,84 @@ +package indexer + +import ( + "path/filepath" + "strings" +) + +// chunkMarkdown splits markdown content by heading boundaries. +func chunkMarkdown(filePath string, content []byte) []Chunk { + text := string(content) + lines := strings.Split(text, "\n") + + type section struct { + heading string + start int // 1-indexed line number + lines []string + } + + var sections []section + current := section{start: 1} + hasHeadings := false + + for i, line := range lines { + if isMarkdownHeading(line) { + hasHeadings = true + // Flush the current section + if len(current.lines) > 0 { + sections = append(sections, current) + } + current = section{ + heading: extractHeadingText(line), + start: i + 1, + lines: []string{line}, + } + } else { + current.lines = append(current.lines, line) + } + } + // Flush final section + if len(current.lines) > 0 { + sections = append(sections, current) + } + + if !hasHeadings { + return chunkWholeFile(filePath, content, "document") + } + + var chunks []Chunk + for _, s := range sections { + body := strings.Join(s.lines, "\n") + if strings.TrimSpace(body) == "" { + continue + } + name := s.heading + if name == "" { + name = filepath.Base(filePath) + } + chunks = append(chunks, Chunk{ + FilePath: filePath, + LineStart: s.start, + LineEnd: s.start + len(s.lines) - 1, + ChunkType: "heading", + ChunkName: name, + Content: body, + }) + } + + if len(chunks) == 0 { + return chunkWholeFile(filePath, content, "document") + } + + return chunks +} + +func isMarkdownHeading(line string) bool { + return strings.HasPrefix(line, "# ") || + strings.HasPrefix(line, "## ") || + strings.HasPrefix(line, "### ") || + strings.HasPrefix(line, "#### ") +} + +func extractHeadingText(line string) string { + return strings.TrimSpace(strings.TrimLeft(line, "# ")) +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/treesitter.go b/go/plugins/gitrepo-mcp/internal/indexer/treesitter.go new file mode 100644 index 000000000..455171a27 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/treesitter.go @@ -0,0 +1,152 @@ +//go:build cgo + +package indexer + +import ( + "context" + "fmt" + + sitter "github.com/smacker/go-tree-sitter" + "github.com/smacker/go-tree-sitter/golang" + "github.com/smacker/go-tree-sitter/java" + "github.com/smacker/go-tree-sitter/javascript" + "github.com/smacker/go-tree-sitter/python" + "github.com/smacker/go-tree-sitter/rust" + "github.com/smacker/go-tree-sitter/typescript/typescript" +) + +// nodeRule defines which tree-sitter node types to extract as chunks. +type nodeRule struct { + NodeType string // tree-sitter AST node type + ChunkType string // our classification (function, method, class, etc.) + NameField string // field name to extract the identifier, or "" for special handling +} + +// langConfig holds tree-sitter language and extraction rules. +type langConfig struct { + Language *sitter.Language + Rules []nodeRule +} + +var langConfigs = map[string]langConfig{ + "go": { + Language: golang.GetLanguage(), + Rules: []nodeRule{ + {"function_declaration", "function", "name"}, + {"method_declaration", "method", "name"}, + {"type_declaration", "type", ""}, + }, + }, + "python": { + Language: python.GetLanguage(), + Rules: []nodeRule{ + {"function_definition", "function", "name"}, + {"class_definition", "class", "name"}, + }, + }, + "javascript": { + Language: javascript.GetLanguage(), + Rules: []nodeRule{ + {"function_declaration", "function", "name"}, + {"class_declaration", "class", "name"}, + }, + }, + "typescript": { + Language: typescript.GetLanguage(), + Rules: []nodeRule{ + {"function_declaration", "function", "name"}, + {"class_declaration", "class", "name"}, + }, + }, + "java": { + Language: java.GetLanguage(), + Rules: []nodeRule{ + {"method_declaration", "method", "name"}, + {"class_declaration", "class", "name"}, + {"interface_declaration", "interface", "name"}, + }, + }, + "rust": { + Language: rust.GetLanguage(), + Rules: []nodeRule{ + {"function_item", "function", "name"}, + {"impl_item", "impl", "type"}, + {"struct_item", "struct", "name"}, + }, + }, +} + +// chunkWithTreeSitter parses source code and extracts structural chunks. +func chunkWithTreeSitter(filePath string, content []byte, lang string) ([]Chunk, error) { + cfg, ok := langConfigs[lang] + if !ok { + return nil, fmt.Errorf("no tree-sitter config for language: %s", lang) + } + + parser := sitter.NewParser() + parser.SetLanguage(cfg.Language) + + tree, err := parser.ParseCtx(context.Background(), nil, content) + if err != nil { + return nil, fmt.Errorf("tree-sitter parse failed for %s: %w", filePath, err) + } + defer tree.Close() + + root := tree.RootNode() + return findChunks(root, content, cfg.Rules, filePath), nil +} + +// findChunks recursively walks the AST and collects chunks matching any rule. +func findChunks(node *sitter.Node, source []byte, rules []nodeRule, filePath string) []Chunk { + var chunks []Chunk + + for _, rule := range rules { + if node.Type() == rule.NodeType { + name := extractNodeName(node, source, rule) + endRow := int(node.EndPoint().Row) + if node.EndPoint().Column == 0 && endRow > 0 { + endRow-- + } + chunk := Chunk{ + FilePath: filePath, + LineStart: int(node.StartPoint().Row) + 1, + LineEnd: endRow + 1, + ChunkType: rule.ChunkType, + ChunkName: name, + Content: node.Content(source), + } + chunks = append(chunks, chunk) + } + } + + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + chunks = append(chunks, findChunks(child, source, rules, filePath)...) + } + + return chunks +} + +// extractNodeName attempts to extract the identifier name from a matched node. +func extractNodeName(node *sitter.Node, source []byte, rule nodeRule) string { + // Special case: Go type_declaration has nested type_spec with the name + if node.Type() == "type_declaration" { + for i := 0; i < int(node.NamedChildCount()); i++ { + child := node.NamedChild(i) + if child.Type() == "type_spec" { + if nameNode := child.ChildByFieldName("name"); nameNode != nil { + return nameNode.Content(source) + } + } + } + return "" + } + + if rule.NameField != "" { + if nameNode := node.ChildByFieldName(rule.NameField); nameNode != nil { + return nameNode.Content(source) + } + } + + return "" +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/treesitter_nocgo.go b/go/plugins/gitrepo-mcp/internal/indexer/treesitter_nocgo.go new file mode 100644 index 000000000..d371db17f --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/treesitter_nocgo.go @@ -0,0 +1,11 @@ +//go:build !cgo + +package indexer + +import "fmt" + +// chunkWithTreeSitter is a no-op fallback when CGo is not available. +// All tree-sitter-supported languages fall back to whole-file chunking. +func chunkWithTreeSitter(filePath string, content []byte, lang string) ([]Chunk, error) { + return nil, fmt.Errorf("tree-sitter chunking requires CGo (CGO_ENABLED=1)") +} diff --git a/go/plugins/gitrepo-mcp/internal/indexer/treesitter_test.go b/go/plugins/gitrepo-mcp/internal/indexer/treesitter_test.go new file mode 100644 index 000000000..18278dc40 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/indexer/treesitter_test.go @@ -0,0 +1,297 @@ +//go:build cgo + +package indexer + +import ( + "testing" +) + +func TestChunkGoFile(t *testing.T) { + source := `package main + +import "fmt" + +func hello() { + fmt.Println("hello") +} + +func add(a, b int) int { + return a + b +} + +type Config struct { + Name string + Port int +} +` + chunks, err := ChunkFile("main.go", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 3 { + t.Fatalf("expected 3 chunks (hello, add, Config), got %d", len(chunks)) + } + + assertChunk(t, chunks[0], "function", "hello", 5, 7) + assertChunk(t, chunks[1], "function", "add", 9, 11) + assertChunk(t, chunks[2], "type", "Config", 13, 16) +} + +func TestChunkGoMethod(t *testing.T) { + source := `package main + +type Server struct { + port int +} + +func (s *Server) Start() error { + return nil +} + +func (s *Server) Stop() { +} +` + chunks, err := ChunkFile("server.go", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 3 { + t.Fatalf("expected 3 chunks, got %d", len(chunks)) + } + + assertChunk(t, chunks[0], "type", "Server", 3, 5) + assertChunk(t, chunks[1], "method", "Start", 7, 9) + assertChunk(t, chunks[2], "method", "Stop", 11, 12) +} + +func TestChunkPythonFile(t *testing.T) { + source := `class Greeter: + def __init__(self, name): + self.name = name + + def greet(self): + return f"Hello, {self.name}!" + +def standalone(): + pass +` + chunks, err := ChunkFile("app.py", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) < 3 { + t.Fatalf("expected at least 3 chunks, got %d", len(chunks)) + } + + found := findChunkByName(chunks, "Greeter") + if found == nil { + t.Fatal("expected chunk for class Greeter") + } + if found.ChunkType != "class" { + t.Errorf("Greeter chunk type = %q, want %q", found.ChunkType, "class") + } + + found = findChunkByName(chunks, "standalone") + if found == nil { + t.Fatal("expected chunk for function standalone") + } + if found.ChunkType != "function" { + t.Errorf("standalone chunk type = %q, want %q", found.ChunkType, "function") + } +} + +func TestChunkJavaScriptFile(t *testing.T) { + source := `function greet(name) { + return "Hello, " + name; +} + +class Calculator { + add(a, b) { + return a + b; + } +} +` + chunks, err := ChunkFile("app.js", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) < 2 { + t.Fatalf("expected at least 2 chunks, got %d", len(chunks)) + } + + found := findChunkByName(chunks, "greet") + if found == nil { + t.Fatal("expected chunk for function greet") + } + if found.ChunkType != "function" { + t.Errorf("greet chunk type = %q, want %q", found.ChunkType, "function") + } + + found = findChunkByName(chunks, "Calculator") + if found == nil { + t.Fatal("expected chunk for class Calculator") + } +} + +func TestChunkTypeScriptFile(t *testing.T) { + source := `function fetchData(url: string): Promise { + return fetch(url); +} + +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } +} +` + chunks, err := ChunkFile("api.ts", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) < 2 { + t.Fatalf("expected at least 2 chunks, got %d", len(chunks)) + } + + found := findChunkByName(chunks, "fetchData") + if found == nil { + t.Fatal("expected chunk for function fetchData") + } + + found = findChunkByName(chunks, "ApiClient") + if found == nil { + t.Fatal("expected chunk for class ApiClient") + } +} + +func TestChunkJavaFile(t *testing.T) { + source := `public class Calculator { + public int add(int a, int b) { + return a + b; + } + + public int subtract(int a, int b) { + return a - b; + } +} +` + chunks, err := ChunkFile("Calculator.java", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) < 2 { + t.Fatalf("expected at least 2 chunks, got %d", len(chunks)) + } + + found := findChunkByName(chunks, "Calculator") + if found == nil { + t.Fatal("expected chunk for class Calculator") + } + if found.ChunkType != "class" { + t.Errorf("Calculator chunk type = %q, want %q", found.ChunkType, "class") + } + + found = findChunkByName(chunks, "add") + if found == nil { + t.Fatal("expected chunk for method add") + } + if found.ChunkType != "method" { + t.Errorf("add chunk type = %q, want %q", found.ChunkType, "method") + } +} + +func TestChunkRustFile(t *testing.T) { + source := `struct Config { + name: String, + port: u16, +} + +impl Config { + fn new(name: String, port: u16) -> Self { + Config { name, port } + } +} + +fn main() { + let cfg = Config::new("test".to_string(), 8080); +} +` + chunks, err := ChunkFile("main.rs", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) < 3 { + t.Fatalf("expected at least 3 chunks, got %d", len(chunks)) + } + + found := findChunkByName(chunks, "Config") + if found == nil { + t.Fatal("expected chunk for struct Config") + } + + found = findChunkByName(chunks, "main") + if found == nil { + t.Fatal("expected chunk for function main") + } +} + +func TestChunkGoLineNumbers(t *testing.T) { + source := `package main + +func first() { + // line 4 +} + +func second() { + // line 8 + // line 9 +} +` + chunks, err := ChunkFile("lines.go", []byte(source)) + if err != nil { + t.Fatalf("ChunkFile failed: %v", err) + } + + if len(chunks) != 2 { + t.Fatalf("expected 2 chunks, got %d", len(chunks)) + } + + if chunks[0].LineStart != 3 { + t.Errorf("first() LineStart = %d, want 3", chunks[0].LineStart) + } + if chunks[0].LineEnd != 5 { + t.Errorf("first() LineEnd = %d, want 5", chunks[0].LineEnd) + } + + if chunks[1].LineStart != 7 { + t.Errorf("second() LineStart = %d, want 7", chunks[1].LineStart) + } + if chunks[1].LineEnd != 10 { + t.Errorf("second() LineEnd = %d, want 10", chunks[1].LineEnd) + } +} + +// assertChunk is a helper for tree-sitter chunk assertions. +func assertChunk(t *testing.T, c Chunk, wantType, wantName string, wantStart, wantEnd int) { + t.Helper() + if c.ChunkType != wantType { + t.Errorf("chunk %q type = %q, want %q", c.ChunkName, c.ChunkType, wantType) + } + if c.ChunkName != wantName { + t.Errorf("chunk name = %q, want %q", c.ChunkName, wantName) + } + if c.LineStart != wantStart { + t.Errorf("chunk %q LineStart = %d, want %d", c.ChunkName, c.LineStart, wantStart) + } + if c.LineEnd != wantEnd { + t.Errorf("chunk %q LineEnd = %d, want %d", c.ChunkName, c.LineEnd, wantEnd) + } +} diff --git a/go/plugins/gitrepo-mcp/internal/repo/manager.go b/go/plugins/gitrepo-mcp/internal/repo/manager.go new file mode 100644 index 000000000..e29965e84 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/repo/manager.go @@ -0,0 +1,226 @@ +package repo + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +// Manager handles git repository lifecycle operations. +type Manager struct { + repoStore *storage.RepoStore + reposDir string +} + +// NewManager creates a repo Manager. reposDir is the directory where repos are cloned. +func NewManager(repoStore *storage.RepoStore, reposDir string) *Manager { + return &Manager{ + repoStore: repoStore, + reposDir: reposDir, + } +} + +// Add clones a git repository and registers it in the database. +func (m *Manager) Add(name, url, branch string) (*storage.Repo, error) { + if name == "" { + return nil, fmt.Errorf("repo name is required") + } + if url == "" { + return nil, fmt.Errorf("repo URL is required") + } + if branch == "" { + branch = "main" + } + + localPath := filepath.Join(m.reposDir, name) + + repo := &storage.Repo{ + Name: name, + URL: url, + Branch: branch, + Status: storage.RepoStatusCloning, + LocalPath: localPath, + } + + if err := m.repoStore.Create(repo); err != nil { + return nil, fmt.Errorf("failed to register repo %s: %w", name, err) + } + + if err := m.cloneRepo(url, branch, localPath); err != nil { + errMsg := err.Error() + repo.Status = storage.RepoStatusError + repo.Error = &errMsg + _ = m.repoStore.Update(repo) + return nil, fmt.Errorf("failed to clone repo %s: %w", name, err) + } + + now := time.Now() + repo.Status = storage.RepoStatusCloned + repo.LastSynced = &now + if err := m.repoStore.Update(repo); err != nil { + return nil, fmt.Errorf("failed to update repo status: %w", err) + } + + return repo, nil +} + +// Get returns a single repo by name. +func (m *Manager) Get(name string) (*storage.Repo, error) { + return m.repoStore.Get(name) +} + +// List returns all registered repos. +func (m *Manager) List() ([]storage.Repo, error) { + return m.repoStore.List() +} + +// Remove deletes a repo from the database and removes its cloned directory. +func (m *Manager) Remove(name string) error { + repo, err := m.repoStore.Get(name) + if err != nil { + return fmt.Errorf("repo %s not found: %w", name, err) + } + + if repo.LocalPath != "" { + if err := os.RemoveAll(repo.LocalPath); err != nil { + return fmt.Errorf("failed to remove repo directory %s: %w", repo.LocalPath, err) + } + } + + if err := m.repoStore.Delete(name); err != nil { + return fmt.Errorf("failed to delete repo %s from database: %w", name, err) + } + + return nil +} + +// SyncResult holds the result of syncing a single repo. +type SyncResult struct { + Name string `json:"name"` + Synced bool `json:"synced"` + Reindexed bool `json:"reindexed"` + Error string `json:"error,omitempty"` +} + +// Sync pulls latest changes for a repo. +func (m *Manager) Sync(name string) (*storage.Repo, error) { + repo, err := m.repoStore.Get(name) + if err != nil { + return nil, fmt.Errorf("repo %s not found: %w", name, err) + } + + if repo.Status == storage.RepoStatusCloning || repo.Status == storage.RepoStatusIndexing { + return nil, fmt.Errorf("repo %s is busy (status: %s)", name, repo.Status) + } + + if err := m.pullRepo(repo.LocalPath); err != nil { + errMsg := err.Error() + repo.Status = storage.RepoStatusError + repo.Error = &errMsg + _ = m.repoStore.Update(repo) + return nil, fmt.Errorf("failed to sync repo %s: %w", name, err) + } + + now := time.Now() + repo.LastSynced = &now + repo.Error = nil + if repo.Status == storage.RepoStatusError { + repo.Status = storage.RepoStatusCloned + } + if err := m.repoStore.Update(repo); err != nil { + return nil, fmt.Errorf("failed to update repo after sync: %w", err) + } + + return repo, nil +} + +// SyncAndReindex syncs a repo and triggers re-indexing if it was previously indexed. +// reindexFn is called when the repo has status "indexed"; pass nil to skip re-indexing. +func (m *Manager) SyncAndReindex(name string, reindexFn func(string) error) (*storage.Repo, bool, error) { + repo, err := m.Sync(name) + if err != nil { + return nil, false, err + } + + if repo.Status == storage.RepoStatusIndexed && reindexFn != nil { + if err := reindexFn(name); err != nil { + return repo, false, fmt.Errorf("sync succeeded but re-index failed for %s: %w", name, err) + } + repo, err = m.repoStore.Get(name) + if err != nil { + return nil, true, fmt.Errorf("failed to refresh repo after re-index: %w", err) + } + return repo, true, nil + } + + return repo, false, nil +} + +// SyncAll syncs all repos, optionally triggering re-index for indexed repos. +// Repos with busy status (cloning/indexing) are skipped. +func (m *Manager) SyncAll(reindexFn func(string) error) ([]SyncResult, error) { + repos, err := m.repoStore.List() + if err != nil { + return nil, fmt.Errorf("failed to list repos: %w", err) + } + + var results []SyncResult + for _, r := range repos { + result := SyncResult{Name: r.Name} + + if r.Status == storage.RepoStatusCloning || r.Status == storage.RepoStatusIndexing { + result.Error = fmt.Sprintf("skipped: repo is busy (status: %s)", r.Status) + results = append(results, result) + continue + } + + _, reindexed, err := m.SyncAndReindex(r.Name, reindexFn) + if err != nil { + result.Error = err.Error() + } else { + result.Synced = true + result.Reindexed = reindexed + } + + results = append(results, result) + } + + return results, nil +} + +// cloneRepo runs git clone with shallow depth. +func (m *Manager) cloneRepo(url, branch, dest string) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + cmd := exec.Command("git", "clone", + "--branch", branch, + "--single-branch", + "--depth", "1", + url, dest, + ) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + return nil +} + +// pullRepo runs git pull in the repo directory. +func (m *Manager) pullRepo(dir string) error { + cmd := exec.Command("git", "-C", dir, "pull", "--ff-only") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git pull failed: %w", err) + } + return nil +} diff --git a/go/plugins/gitrepo-mcp/internal/repo/manager_test.go b/go/plugins/gitrepo-mcp/internal/repo/manager_test.go new file mode 100644 index 000000000..867854f72 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/repo/manager_test.go @@ -0,0 +1,384 @@ +package repo + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" + "github.com/stretchr/testify/require" +) + +func newTestManager(t *testing.T) (*Manager, *storage.RepoStore) { + t.Helper() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: ":memory:", + } + dbMgr, err := storage.NewManager(cfg) + require.NoError(t, err) + require.NoError(t, dbMgr.Initialize()) + + repoStore := storage.NewRepoStore(dbMgr.DB()) + reposDir := t.TempDir() + mgr := NewManager(repoStore, reposDir) + return mgr, repoStore +} + +func TestManager_Add_ValidationErrors(t *testing.T) { + mgr, _ := newTestManager(t) + + _, err := mgr.Add("", "https://example.com/repo.git", "main") + require.Error(t, err) + require.Contains(t, err.Error(), "repo name is required") + + _, err = mgr.Add("test", "", "main") + require.Error(t, err) + require.Contains(t, err.Error(), "repo URL is required") +} + +func TestManager_Add_DuplicateName(t *testing.T) { + mgr, repoStore := newTestManager(t) + + // Pre-create a repo in DB to simulate duplicate. + err := repoStore.Create(&storage.Repo{ + Name: "existing", + URL: "https://example.com/existing.git", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: "/tmp/existing", + }) + require.NoError(t, err) + + _, err = mgr.Add("existing", "https://example.com/other.git", "main") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to register repo") +} + +func TestManager_Add_CloneFailure(t *testing.T) { + mgr, repoStore := newTestManager(t) + + // Use a URL that will definitely fail to clone. + _, err := mgr.Add("badrepo", "https://invalid.example.com/nonexistent.git", "main") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to clone repo") + + // Verify the repo was saved with error status. + repo, err := repoStore.Get("badrepo") + require.NoError(t, err) + require.Equal(t, storage.RepoStatusError, repo.Status) + require.NotNil(t, repo.Error) +} + +func TestManager_Remove_NotFound(t *testing.T) { + mgr, _ := newTestManager(t) + + err := mgr.Remove("nonexistent") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestManager_Remove_CleansUpDirectory(t *testing.T) { + mgr, repoStore := newTestManager(t) + + // Create a repo record and a fake directory. + repoDir := filepath.Join(mgr.reposDir, "myrepo") + require.NoError(t, os.MkdirAll(repoDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("hello"), 0o644)) + + err := repoStore.Create(&storage.Repo{ + Name: "myrepo", + URL: "https://example.com/myrepo.git", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: repoDir, + }) + require.NoError(t, err) + + // Remove should delete both DB record and directory. + require.NoError(t, mgr.Remove("myrepo")) + + _, err = repoStore.Get("myrepo") + require.Error(t, err) + + _, err = os.Stat(repoDir) + require.True(t, os.IsNotExist(err)) +} + +func TestManager_Get(t *testing.T) { + mgr, repoStore := newTestManager(t) + + err := repoStore.Create(&storage.Repo{ + Name: "testrepo", + URL: "https://example.com/test.git", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: "/tmp/testrepo", + }) + require.NoError(t, err) + + repo, err := mgr.Get("testrepo") + require.NoError(t, err) + require.Equal(t, "testrepo", repo.Name) + require.Equal(t, "https://example.com/test.git", repo.URL) +} + +func TestManager_Get_NotFound(t *testing.T) { + mgr, _ := newTestManager(t) + + _, err := mgr.Get("nonexistent") + require.Error(t, err) +} + +func TestManager_List(t *testing.T) { + mgr, repoStore := newTestManager(t) + + // Empty list. + repos, err := mgr.List() + require.NoError(t, err) + require.Empty(t, repos) + + // Add some repos. + for _, name := range []string{"bravo", "alpha", "charlie"} { + err := repoStore.Create(&storage.Repo{ + Name: name, + URL: "https://example.com/" + name + ".git", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: "/tmp/" + name, + }) + require.NoError(t, err) + } + + repos, err = mgr.List() + require.NoError(t, err) + require.Len(t, repos, 3) + require.Equal(t, "alpha", repos[0].Name) + require.Equal(t, "bravo", repos[1].Name) + require.Equal(t, "charlie", repos[2].Name) +} + +func TestManager_Sync_NotFound(t *testing.T) { + mgr, _ := newTestManager(t) + + _, err := mgr.Sync("nonexistent") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +func TestManager_Sync_BusyStatus(t *testing.T) { + mgr, repoStore := newTestManager(t) + + err := repoStore.Create(&storage.Repo{ + Name: "busyrepo", + URL: "https://example.com/busy.git", + Branch: "main", + Status: storage.RepoStatusCloning, + LocalPath: "/tmp/busyrepo", + }) + require.NoError(t, err) + + _, err = mgr.Sync("busyrepo") + require.Error(t, err) + require.Contains(t, err.Error(), "busy") +} + +func TestManager_DefaultBranch(t *testing.T) { + mgr, _ := newTestManager(t) + + // Add with empty branch should fail at clone (bad URL), but the DB record + // should have branch defaulted to "main". + _, _ = mgr.Add("defaultbranch", "https://invalid.example.com/x.git", "") + + repo, err := mgr.Get("defaultbranch") + require.NoError(t, err) + require.Equal(t, "main", repo.Branch) +} + +// --- git repo helper for sync tests --- + +// createClonedGitRepo creates a bare repo + clone in reposDir/name, suitable for git pull. +func createClonedGitRepo(t *testing.T, reposDir, name string) { + t.Helper() + + bareDir := filepath.Join(t.TempDir(), name+"-bare.git") + runGit(t, "", "init", "--bare", "--initial-branch=main", bareDir) + + cloneDir := filepath.Join(reposDir, name) + runGit(t, "", "clone", bareDir, cloneDir) + + require.NoError(t, os.WriteFile(filepath.Join(cloneDir, "file.txt"), []byte("hello"), 0o644)) + runGit(t, cloneDir, "add", "file.txt") + runGitEnv(t, cloneDir, "commit", "-m", "initial") + runGit(t, cloneDir, "push", "origin", "main") +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +func runGitEnv(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +// --- SyncAndReindex --- + +func TestManager_SyncAndReindex_IndexedRepo(t *testing.T) { + mgr, repoStore := newTestManager(t) + + createClonedGitRepo(t, mgr.reposDir, "myrepo") + + err := repoStore.Create(&storage.Repo{ + Name: "myrepo", + URL: "https://example.com/myrepo.git", + Branch: "main", + Status: storage.RepoStatusIndexed, + LocalPath: filepath.Join(mgr.reposDir, "myrepo"), + }) + require.NoError(t, err) + + var reindexCalled bool + reindexFn := func(name string) error { + reindexCalled = true + require.Equal(t, "myrepo", name) + return nil + } + + repo, reindexed, err := mgr.SyncAndReindex("myrepo", reindexFn) + require.NoError(t, err) + require.True(t, reindexed) + require.True(t, reindexCalled) + require.NotNil(t, repo.LastSynced) +} + +func TestManager_SyncAndReindex_ClonedRepo_NoReindex(t *testing.T) { + mgr, repoStore := newTestManager(t) + + createClonedGitRepo(t, mgr.reposDir, "clonedrepo") + + err := repoStore.Create(&storage.Repo{ + Name: "clonedrepo", + URL: "https://example.com/cloned.git", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: filepath.Join(mgr.reposDir, "clonedrepo"), + }) + require.NoError(t, err) + + reindexCalled := false + reindexFn := func(_ string) error { + reindexCalled = true + return nil + } + + repo, reindexed, err := mgr.SyncAndReindex("clonedrepo", reindexFn) + require.NoError(t, err) + require.False(t, reindexed) + require.False(t, reindexCalled) + require.NotNil(t, repo.LastSynced) +} + +func TestManager_SyncAndReindex_NilReindexFn(t *testing.T) { + mgr, repoStore := newTestManager(t) + + createClonedGitRepo(t, mgr.reposDir, "nilreindex") + + err := repoStore.Create(&storage.Repo{ + Name: "nilreindex", + URL: "https://example.com/nil.git", + Branch: "main", + Status: storage.RepoStatusIndexed, + LocalPath: filepath.Join(mgr.reposDir, "nilreindex"), + }) + require.NoError(t, err) + + repo, reindexed, err := mgr.SyncAndReindex("nilreindex", nil) + require.NoError(t, err) + require.False(t, reindexed) + require.NotNil(t, repo.LastSynced) +} + +func TestManager_SyncAndReindex_NotFound(t *testing.T) { + mgr, _ := newTestManager(t) + + _, _, err := mgr.SyncAndReindex("nonexistent", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +// --- SyncAll --- + +func TestManager_SyncAll_Empty(t *testing.T) { + mgr, _ := newTestManager(t) + + results, err := mgr.SyncAll(nil) + require.NoError(t, err) + require.Empty(t, results) +} + +func TestManager_SyncAll_SkipsBusy(t *testing.T) { + mgr, repoStore := newTestManager(t) + + err := repoStore.Create(&storage.Repo{ + Name: "busy", URL: "https://example.com/busy.git", Branch: "main", + Status: storage.RepoStatusCloning, LocalPath: "/tmp/busy", + }) + require.NoError(t, err) + + results, err := mgr.SyncAll(nil) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, "busy", results[0].Name) + require.False(t, results[0].Synced) + require.Contains(t, results[0].Error, "busy") +} + +func TestManager_SyncAll_MixedResults(t *testing.T) { + mgr, repoStore := newTestManager(t) + + // Create a syncable repo with real git + createClonedGitRepo(t, mgr.reposDir, "good") + err := repoStore.Create(&storage.Repo{ + Name: "good", URL: "https://example.com/good.git", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: filepath.Join(mgr.reposDir, "good"), + }) + require.NoError(t, err) + + // Create a busy repo + err = repoStore.Create(&storage.Repo{ + Name: "busy", URL: "https://example.com/busy.git", Branch: "main", + Status: storage.RepoStatusIndexing, LocalPath: "/tmp/busy", + }) + require.NoError(t, err) + + results, err := mgr.SyncAll(nil) + require.NoError(t, err) + require.Len(t, results, 2) + + // Results are ordered by name (alpha sort from DB) + require.Equal(t, "busy", results[0].Name) + require.False(t, results[0].Synced) + require.Contains(t, results[0].Error, "busy") + + require.Equal(t, "good", results[1].Name) + require.True(t, results[1].Synced) + require.False(t, results[1].Reindexed) +} diff --git a/go/plugins/gitrepo-mcp/internal/search/context.go b/go/plugins/gitrepo-mcp/internal/search/context.go new file mode 100644 index 000000000..802e87830 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/search/context.go @@ -0,0 +1,87 @@ +package search + +import ( + "bufio" + "fmt" + "os" + "path/filepath" +) + +// Context holds lines before and after a code chunk for display purposes. +type Context struct { + Before []string `json:"before,omitempty"` + After []string `json:"after,omitempty"` +} + +// ExtractContext reads surrounding lines from a file on disk. +// lineStart and lineEnd are 1-indexed. contextLines is the number of lines +// to include before and after the chunk. +func ExtractContext(repoPath, filePath string, lineStart, lineEnd, contextLines int) (*Context, error) { + if contextLines <= 0 { + return nil, nil + } + + fullPath := filepath.Join(repoPath, filePath) + f, err := os.Open(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to open %s: %w", fullPath, err) + } + defer f.Close() + + // Read all lines + var lines []string + scanner := bufio.NewScanner(f) + // Increase buffer size for long lines + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read %s: %w", fullPath, err) + } + + totalLines := len(lines) + if totalLines == 0 { + return nil, nil + } + + // Convert to 0-indexed + startIdx := lineStart - 1 + endIdx := lineEnd - 1 + + // Clamp indices + if startIdx < 0 { + startIdx = 0 + } + if endIdx >= totalLines { + endIdx = totalLines - 1 + } + + // Extract before lines + beforeStart := startIdx - contextLines + if beforeStart < 0 { + beforeStart = 0 + } + var before []string + if beforeStart < startIdx { + before = make([]string, startIdx-beforeStart) + copy(before, lines[beforeStart:startIdx]) + } + + // Extract after lines + afterEnd := endIdx + 1 + contextLines + if afterEnd > totalLines { + afterEnd = totalLines + } + var after []string + if endIdx+1 < afterEnd { + after = make([]string, afterEnd-endIdx-1) + copy(after, lines[endIdx+1:afterEnd]) + } + + if len(before) == 0 && len(after) == 0 { + return nil, nil + } + + return &Context{Before: before, After: after}, nil +} diff --git a/go/plugins/gitrepo-mcp/internal/search/search_test.go b/go/plugins/gitrepo-mcp/internal/search/search_test.go new file mode 100644 index 000000000..cf7727449 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/search/search_test.go @@ -0,0 +1,516 @@ +package search + +import ( + "math" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +// --- Cosine similarity tests --- + +func TestCosineSimilarity_Identical(t *testing.T) { + a := []float32{1, 2, 3, 4} + score := CosineSimilarity(a, a) + if math.Abs(score-1.0) > 1e-6 { + t.Errorf("identical vectors: want 1.0, got %f", score) + } +} + +func TestCosineSimilarity_Orthogonal(t *testing.T) { + a := []float32{1, 0, 0} + b := []float32{0, 1, 0} + score := CosineSimilarity(a, b) + if math.Abs(score) > 1e-6 { + t.Errorf("orthogonal vectors: want 0.0, got %f", score) + } +} + +func TestCosineSimilarity_Opposite(t *testing.T) { + a := []float32{1, 2, 3} + b := []float32{-1, -2, -3} + score := CosineSimilarity(a, b) + if math.Abs(score+1.0) > 1e-6 { + t.Errorf("opposite vectors: want -1.0, got %f", score) + } +} + +func TestCosineSimilarity_KnownAngle(t *testing.T) { + // 45 degrees: cos(45°) ≈ 0.7071 + a := []float32{1, 0} + b := []float32{1, 1} + score := CosineSimilarity(a, b) + expected := 1.0 / math.Sqrt(2.0) + if math.Abs(score-expected) > 1e-6 { + t.Errorf("45-degree angle: want %f, got %f", expected, score) + } +} + +func TestCosineSimilarity_EmptyVectors(t *testing.T) { + score := CosineSimilarity(nil, nil) + if score != 0 { + t.Errorf("empty vectors: want 0.0, got %f", score) + } +} + +func TestCosineSimilarity_DifferentLengths(t *testing.T) { + a := []float32{1, 2, 3} + b := []float32{1, 2} + score := CosineSimilarity(a, b) + if score != 0 { + t.Errorf("different lengths: want 0.0, got %f", score) + } +} + +func TestCosineSimilarity_ZeroVector(t *testing.T) { + a := []float32{0, 0, 0} + b := []float32{1, 2, 3} + score := CosineSimilarity(a, b) + if score != 0 { + t.Errorf("zero vector: want 0.0, got %f", score) + } +} + +// --- Context extraction tests --- + +func TestExtractContext_Basic(t *testing.T) { + dir := t.TempDir() + content := "line1\nline2\nline3\nline4\nline5\nline6\nline7\n" + if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ctx, err := ExtractContext(dir, "test.go", 3, 5, 2) + if err != nil { + t.Fatal(err) + } + if ctx == nil { + t.Fatal("expected context, got nil") + } + + if len(ctx.Before) != 2 { + t.Errorf("before lines: want 2, got %d", len(ctx.Before)) + } + if ctx.Before[0] != "line1" || ctx.Before[1] != "line2" { + t.Errorf("before: want [line1, line2], got %v", ctx.Before) + } + + if len(ctx.After) != 2 { + t.Errorf("after lines: want 2, got %d", len(ctx.After)) + } + if ctx.After[0] != "line6" || ctx.After[1] != "line7" { + t.Errorf("after: want [line6, line7], got %v", ctx.After) + } +} + +func TestExtractContext_AtFileStart(t *testing.T) { + dir := t.TempDir() + content := "line1\nline2\nline3\nline4\nline5\n" + if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ctx, err := ExtractContext(dir, "test.go", 1, 2, 3) + if err != nil { + t.Fatal(err) + } + if ctx == nil { + t.Fatal("expected context, got nil") + } + + if len(ctx.Before) != 0 { + t.Errorf("before at start: want 0, got %d", len(ctx.Before)) + } + if len(ctx.After) != 3 { + t.Errorf("after: want 3, got %d", len(ctx.After)) + } +} + +func TestExtractContext_AtFileEnd(t *testing.T) { + dir := t.TempDir() + content := "line1\nline2\nline3\nline4\nline5\n" + if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ctx, err := ExtractContext(dir, "test.go", 4, 5, 3) + if err != nil { + t.Fatal(err) + } + if ctx == nil { + t.Fatal("expected context, got nil") + } + + if len(ctx.Before) != 3 { + t.Errorf("before: want 3, got %d", len(ctx.Before)) + } + if len(ctx.After) != 0 { + t.Errorf("after at end: want 0, got %d", len(ctx.After)) + } +} + +func TestExtractContext_ZeroContextLines(t *testing.T) { + dir := t.TempDir() + content := "line1\nline2\nline3\n" + if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ctx, err := ExtractContext(dir, "test.go", 1, 3, 0) + if err != nil { + t.Fatal(err) + } + if ctx != nil { + t.Errorf("zero context: want nil, got %+v", ctx) + } +} + +func TestExtractContext_FileNotFound(t *testing.T) { + _, err := ExtractContext(t.TempDir(), "missing.go", 1, 1, 1) + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestExtractContext_SingleLineFile(t *testing.T) { + dir := t.TempDir() + content := "only line" + if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ctx, err := ExtractContext(dir, "test.go", 1, 1, 3) + if err != nil { + t.Fatal(err) + } + // No before or after lines for a single-line file + if ctx != nil { + t.Errorf("single line file: want nil context, got %+v", ctx) + } +} + +// --- Searcher integration tests (using in-memory DB + HashEmbedder) --- + +func setupTestSearcher(t *testing.T) (*Searcher, *storage.RepoStore, *storage.EmbeddingStore, embedder.EmbeddingModel) { + t.Helper() + + dir := t.TempDir() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: filepath.Join(dir, "test.db"), + DataDir: dir, + } + mgr, err := storage.NewManager(cfg) + if err != nil { + t.Fatal(err) + } + if err := mgr.Initialize(); err != nil { + t.Fatal(err) + } + + repoStore := storage.NewRepoStore(mgr.DB()) + embStore := storage.NewEmbeddingStore(mgr.DB()) + emb := embedder.NewHashEmbedder(64) + + s := NewSearcher(repoStore, embStore, emb) + return s, repoStore, embStore, emb +} + +func createIndexedRepo(t *testing.T, repoStore *storage.RepoStore, embStore *storage.EmbeddingStore, emb embedder.EmbeddingModel, name, localPath string, chunkTexts []string) { + t.Helper() + + now := time.Now() + repo := &storage.Repo{ + Name: name, + URL: "https://example.com/" + name, + Branch: "main", + Status: storage.RepoStatusIndexed, + LocalPath: localPath, + LastIndexed: &now, + FileCount: 1, + ChunkCount: len(chunkTexts), + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + coll, err := embStore.GetOrCreateCollection(name, emb.ModelName(), emb.Dimensions()) + if err != nil { + t.Fatal(err) + } + + vectors, err := emb.EmbedBatch(chunkTexts) + if err != nil { + t.Fatal(err) + } + + chunks := make([]storage.Chunk, len(chunkTexts)) + for i, text := range chunkTexts { + n := "chunk" + string(rune('A'+i)) + chunks[i] = storage.Chunk{ + CollectionID: coll.ID, + FilePath: "test.go", + LineStart: i*10 + 1, + LineEnd: i*10 + 10, + ChunkType: "function", + ChunkName: &n, + Content: text, + ContentHash: "hash" + string(rune('A'+i)), + Embedding: storage.EncodeEmbedding(vectors[i]), + } + } + + if err := embStore.InsertChunks(chunks); err != nil { + t.Fatal(err) + } +} + +func TestSearcher_BasicSearch(t *testing.T) { + s, repoStore, embStore, emb := setupTestSearcher(t) + + texts := []string{ + "func authenticate(user, pass string) error", + "func listUsers() []User", + "func parseConfig(path string) Config", + } + createIndexedRepo(t, repoStore, embStore, emb, "test-repo", t.TempDir(), texts) + + // Search with exact chunk text — HashEmbedder is deterministic so identical text → score 1.0 + results, err := s.Search(texts[0], "test-repo", 10, 0) + if err != nil { + t.Fatal(err) + } + + if len(results) != 3 { + t.Fatalf("want 3 results, got %d", len(results)) + } + + // First result should be the exact match (score 1.0) + if results[0].Content != texts[0] { + t.Errorf("top result: want %q, got %q", texts[0], results[0].Content) + } + if results[0].Score != 1.0 { + t.Errorf("exact match score: want 1.0, got %f", results[0].Score) + } + + // Scores should be descending + for i := 1; i < len(results); i++ { + if results[i].Score > results[i-1].Score { + t.Errorf("results not sorted: score[%d]=%f > score[%d]=%f", i, results[i].Score, i-1, results[i-1].Score) + } + } + + // All results should have repo name + for _, r := range results { + if r.Repo != "test-repo" { + t.Errorf("want repo=test-repo, got %s", r.Repo) + } + } +} + +func TestSearcher_LimitResults(t *testing.T) { + s, repoStore, embStore, emb := setupTestSearcher(t) + + texts := []string{"func a()", "func b()", "func c()", "func d()", "func e()"} + createIndexedRepo(t, repoStore, embStore, emb, "test-repo", t.TempDir(), texts) + + results, err := s.Search("func", "test-repo", 2, 0) + if err != nil { + t.Fatal(err) + } + + if len(results) != 2 { + t.Fatalf("want 2 results (limit), got %d", len(results)) + } +} + +func TestSearcher_EmptyQuery(t *testing.T) { + s, _, _, _ := setupTestSearcher(t) + + _, err := s.Search("", "test-repo", 10, 0) + if err == nil { + t.Error("expected error for empty query") + } +} + +func TestSearcher_RepoNotFound(t *testing.T) { + s, _, _, _ := setupTestSearcher(t) + + _, err := s.Search("query", "nonexistent", 10, 0) + if err == nil { + t.Error("expected error for missing repo") + } +} + +func TestSearcher_RepoNotIndexed(t *testing.T) { + s, repoStore, _, _ := setupTestSearcher(t) + + repo := &storage.Repo{ + Name: "unindexed", + URL: "https://example.com/unindexed", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: "/tmp/unindexed", + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + _, err := s.Search("query", "unindexed", 10, 0) + if err == nil { + t.Error("expected error for unindexed repo") + } +} + +func TestSearcher_EmptyRepo(t *testing.T) { + s, repoStore, _, _ := setupTestSearcher(t) + + now := time.Now() + repo := &storage.Repo{ + Name: "empty-repo", + URL: "https://example.com/empty", + Branch: "main", + Status: storage.RepoStatusIndexed, + LocalPath: "/tmp/empty", + LastIndexed: &now, + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + results, err := s.Search("query", "empty-repo", 10, 0) + if err != nil { + t.Fatal(err) + } + if len(results) != 0 { + t.Errorf("want 0 results for empty repo, got %d", len(results)) + } +} + +func TestSearcher_WithContext(t *testing.T) { + s, repoStore, embStore, emb := setupTestSearcher(t) + + // Create a real file for context extraction + dir := t.TempDir() + content := "package main\n\nimport \"fmt\"\n\nfunc hello() {\n\tfmt.Println(\"hello\")\n}\n\nfunc world() {\n\tfmt.Println(\"world\")\n}\n" + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + // Create indexed repo with file pointing to real dir + now := time.Now() + repo := &storage.Repo{ + Name: "ctx-repo", + URL: "https://example.com/ctx", + Branch: "main", + Status: storage.RepoStatusIndexed, + LocalPath: dir, + LastIndexed: &now, + FileCount: 1, + ChunkCount: 1, + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + coll, err := embStore.GetOrCreateCollection("ctx-repo", emb.ModelName(), emb.Dimensions()) + if err != nil { + t.Fatal(err) + } + + chunkContent := "func hello() {\n\tfmt.Println(\"hello\")\n}" + vectors, err := emb.EmbedBatch([]string{chunkContent}) + if err != nil { + t.Fatal(err) + } + + name := "hello" + chunks := []storage.Chunk{{ + CollectionID: coll.ID, + FilePath: "main.go", + LineStart: 5, + LineEnd: 7, + ChunkType: "function", + ChunkName: &name, + Content: chunkContent, + ContentHash: "testhash", + Embedding: storage.EncodeEmbedding(vectors[0]), + }} + if err := embStore.InsertChunks(chunks); err != nil { + t.Fatal(err) + } + + results, err := s.Search("hello function", "ctx-repo", 1, 2) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } + + r := results[0] + if r.Context == nil { + t.Fatal("expected context, got nil") + } + + // Lines 5-7, context=2 → before=lines 3-4, after=lines 8-9 + if len(r.Context.Before) != 2 { + t.Errorf("before lines: want 2, got %d: %v", len(r.Context.Before), r.Context.Before) + } + if len(r.Context.After) != 2 { + t.Errorf("after lines: want 2, got %d: %v", len(r.Context.After), r.Context.After) + } +} + +func TestSearcher_ScoreRounding(t *testing.T) { + s, repoStore, embStore, emb := setupTestSearcher(t) + + texts := []string{"func test()"} + createIndexedRepo(t, repoStore, embStore, emb, "test-repo", t.TempDir(), texts) + + results, err := s.Search("func test()", "test-repo", 1, 0) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } + + // Same text → same embedding → cosine similarity = 1.0 + if results[0].Score != 1.0 { + t.Errorf("identical text score: want 1.0, got %f", results[0].Score) + } +} + +func TestSearcher_ChunkNamePopulated(t *testing.T) { + s, repoStore, embStore, emb := setupTestSearcher(t) + + texts := []string{"func myFunction()"} + createIndexedRepo(t, repoStore, embStore, emb, "test-repo", t.TempDir(), texts) + + results, err := s.Search("myFunction", "test-repo", 1, 0) + if err != nil { + t.Fatal(err) + } + + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } + + if results[0].ChunkName == "" { + t.Error("chunk name should be populated") + } + if results[0].ChunkType != "function" { + t.Errorf("chunk type: want function, got %s", results[0].ChunkType) + } +} diff --git a/go/plugins/gitrepo-mcp/internal/search/semantic.go b/go/plugins/gitrepo-mcp/internal/search/semantic.go new file mode 100644 index 000000000..3b5b317fa --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/search/semantic.go @@ -0,0 +1,168 @@ +package search + +import ( + "fmt" + "math" + "sort" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +// SearchResult represents a single semantic search match. +type SearchResult struct { + Repo string `json:"repo"` + FilePath string `json:"filePath"` + LineStart int `json:"lineStart"` + LineEnd int `json:"lineEnd"` + Score float64 `json:"score"` + ChunkType string `json:"chunkType"` + ChunkName string `json:"chunkName,omitempty"` + Content string `json:"content"` + Context *Context `json:"context,omitempty"` +} + +// Searcher performs semantic search over indexed repositories. +type Searcher struct { + repoStore *storage.RepoStore + embeddingStore *storage.EmbeddingStore + embedder embedder.EmbeddingModel +} + +// NewSearcher creates a Searcher. +func NewSearcher( + repoStore *storage.RepoStore, + embeddingStore *storage.EmbeddingStore, + emb embedder.EmbeddingModel, +) *Searcher { + return &Searcher{ + repoStore: repoStore, + embeddingStore: embeddingStore, + embedder: emb, + } +} + +// Search performs semantic search over a single repo's indexed chunks. +// It embeds the query, computes cosine similarity against all stored embeddings, +// and returns the top-N results sorted by score descending. +func (s *Searcher) Search(query string, repoName string, limit int, contextLines int) ([]SearchResult, error) { + if query == "" { + return nil, fmt.Errorf("query must not be empty") + } + if limit <= 0 { + limit = 10 + } + + // Verify repo exists and is indexed + repo, err := s.repoStore.Get(repoName) + if err != nil { + return nil, fmt.Errorf("repo %s not found: %w", repoName, err) + } + if repo.Status != storage.RepoStatusIndexed { + return nil, fmt.Errorf("repo %s is not indexed (status: %s)", repoName, repo.Status) + } + + // Embed the query + vectors, err := s.embedder.EmbedBatch([]string{query}) + if err != nil { + return nil, fmt.Errorf("failed to embed query: %w", err) + } + queryVec := vectors[0] + + // Load collection and all chunks + coll, err := s.embeddingStore.GetOrCreateCollection( + repoName, + s.embedder.ModelName(), + s.embedder.Dimensions(), + ) + if err != nil { + return nil, fmt.Errorf("failed to get collection: %w", err) + } + + chunks, err := s.embeddingStore.GetChunksByCollection(coll.ID) + if err != nil { + return nil, fmt.Errorf("failed to load chunks: %w", err) + } + + if len(chunks) == 0 { + return nil, nil + } + + // Compute cosine similarity for each chunk + type scored struct { + chunk storage.Chunk + score float64 + } + results := make([]scored, len(chunks)) + for i, c := range chunks { + chunkVec := storage.DecodeEmbedding(c.Embedding) + results[i] = scored{ + chunk: c, + score: CosineSimilarity(queryVec, chunkVec), + } + } + + // Sort by score descending + sort.Slice(results, func(i, j int) bool { + return results[i].score > results[j].score + }) + + // Take top N + if limit > len(results) { + limit = len(results) + } + top := results[:limit] + + // Build SearchResult list with optional context + out := make([]SearchResult, len(top)) + for i, s := range top { + name := "" + if s.chunk.ChunkName != nil { + name = *s.chunk.ChunkName + } + sr := SearchResult{ + Repo: repoName, + FilePath: s.chunk.FilePath, + LineStart: s.chunk.LineStart, + LineEnd: s.chunk.LineEnd, + Score: math.Round(s.score*10000) / 10000, // 4 decimal places + ChunkType: s.chunk.ChunkType, + ChunkName: name, + Content: s.chunk.Content, + } + + if contextLines > 0 { + ctx, err := ExtractContext(repo.LocalPath, s.chunk.FilePath, s.chunk.LineStart, s.chunk.LineEnd, contextLines) + if err == nil { + sr.Context = ctx + } + } + + out[i] = sr + } + + return out, nil +} + +// CosineSimilarity computes dot(a,b) / (||a|| * ||b||). +// Returns 0 if either vector has zero norm. +func CosineSimilarity(a, b []float32) float64 { + if len(a) != len(b) || len(a) == 0 { + return 0 + } + + var dot, normA, normB float64 + for i := range a { + ai := float64(a[i]) + bi := float64(b[i]) + dot += ai * bi + normA += ai * ai + normB += bi * bi + } + + denom := math.Sqrt(normA) * math.Sqrt(normB) + if denom == 0 { + return 0 + } + return dot / denom +} diff --git a/go/plugins/gitrepo-mcp/internal/search/structural.go b/go/plugins/gitrepo-mcp/internal/search/structural.go new file mode 100644 index 000000000..795e69e16 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/search/structural.go @@ -0,0 +1,164 @@ +package search + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +// AstSearchResult represents a single ast-grep match. +type AstSearchResult struct { + FilePath string `json:"filePath"` + LineStart int `json:"lineStart"` + LineEnd int `json:"lineEnd"` + Content string `json:"content"` + MatchedNode string `json:"matchedNode"` + Language string `json:"language"` +} + +// astGrepMatch is the JSON structure returned by `ast-grep --json`. +type astGrepMatch struct { + Text string `json:"text"` + Range astRange `json:"range"` + File string `json:"file"` + Language string `json:"language"` +} + +type astRange struct { + Start astPosition `json:"start"` + End astPosition `json:"end"` +} + +type astPosition struct { + Line int `json:"line"` + Column int `json:"column"` +} + +// AstSearcher performs structural search using ast-grep. +type AstSearcher struct { + repoStore *storage.RepoStore +} + +// NewAstSearcher creates an AstSearcher. +func NewAstSearcher(repoStore *storage.RepoStore) *AstSearcher { + return &AstSearcher{repoStore: repoStore} +} + +// Search runs ast-grep structural search on a repository. +// pattern is the ast-grep pattern (e.g., "func $NAME($$$) error"). +// lang is an optional language filter (e.g., "go", "python"). +func (a *AstSearcher) Search(pattern string, repoName string, lang string) ([]AstSearchResult, error) { + if pattern == "" { + return nil, fmt.Errorf("pattern must not be empty") + } + + repo, err := a.repoStore.Get(repoName) + if err != nil { + return nil, fmt.Errorf("repo %s not found: %w", repoName, err) + } + + if repo.Status != storage.RepoStatusCloned && repo.Status != storage.RepoStatusIndexed { + return nil, fmt.Errorf("repo %s is not ready (status: %s)", repoName, repo.Status) + } + + return runAstGrep(pattern, repo.LocalPath, lang) +} + +// runAstGrep shells out to the ast-grep binary and parses its JSON output. +func runAstGrep(pattern, repoPath, lang string) ([]AstSearchResult, error) { + args := []string{"run", "--pattern", pattern, "--json=stream"} + if lang != "" { + args = append(args, "--lang", lang) + } + args = append(args, repoPath) + + cmd := exec.Command("ast-grep", args...) + + output, err := cmd.Output() + if err != nil { + if execErr, ok := err.(*exec.ExitError); ok { + // ast-grep returns exit code 1 when no matches found + if execErr.ExitCode() == 1 && len(output) == 0 { + return nil, nil + } + // Other exit errors with stderr + stderr := string(execErr.Stderr) + if stderr != "" { + return nil, fmt.Errorf("ast-grep failed: %s", strings.TrimSpace(stderr)) + } + } + if exec.ErrNotFound.Error() == err.Error() || isExecNotFound(err) { + return nil, fmt.Errorf("ast-grep binary not found, install from https://ast-grep.github.io/") + } + return nil, fmt.Errorf("ast-grep failed: %w", err) + } + + return parseAstGrepOutput(output, repoPath) +} + +// isExecNotFound checks if the error indicates the binary was not found. +func isExecNotFound(err error) bool { + return strings.Contains(err.Error(), "executable file not found") +} + +// parseAstGrepOutput parses ast-grep --json=stream output (newline-delimited JSON). +func parseAstGrepOutput(data []byte, repoPath string) ([]AstSearchResult, error) { + if len(data) == 0 { + return nil, nil + } + + var results []AstSearchResult + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var match astGrepMatch + if err := json.Unmarshal([]byte(line), &match); err != nil { + return nil, fmt.Errorf("failed to parse ast-grep output: %w", err) + } + + filePath := match.File + if strings.HasPrefix(filePath, repoPath) { + filePath = strings.TrimPrefix(filePath, repoPath) + filePath = strings.TrimPrefix(filePath, "/") + } + + results = append(results, AstSearchResult{ + FilePath: filePath, + LineStart: match.Range.Start.Line + 1, // ast-grep uses 0-indexed lines + LineEnd: match.Range.End.Line + 1, + Content: match.Text, + MatchedNode: match.Text, + Language: match.Language, + }) + } + + return results, nil +} + +// SupportedLanguages returns the list of languages supported by ast-grep. +func SupportedLanguages() []string { + return []string{ + "c", + "cpp", + "css", + "go", + "html", + "java", + "javascript", + "kotlin", + "lua", + "python", + "rust", + "swift", + "typescript", + "tsx", + } +} diff --git a/go/plugins/gitrepo-mcp/internal/search/structural_test.go b/go/plugins/gitrepo-mcp/internal/search/structural_test.go new file mode 100644 index 000000000..d3c4c3cfe --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/search/structural_test.go @@ -0,0 +1,288 @@ +package search + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +// --- parseAstGrepOutput tests --- + +func TestParseAstGrepOutput_Empty(t *testing.T) { + results, err := parseAstGrepOutput(nil, "/tmp/repo") + if err != nil { + t.Fatal(err) + } + if len(results) != 0 { + t.Errorf("want 0 results, got %d", len(results)) + } +} + +func TestParseAstGrepOutput_SingleMatch(t *testing.T) { + jsonLine := `{"text":"func hello() error","range":{"start":{"line":10,"column":0},"end":{"line":12,"column":1}},"file":"/tmp/repo/main.go","language":"Go"}` + + results, err := parseAstGrepOutput([]byte(jsonLine), "/tmp/repo") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } + + r := results[0] + if r.FilePath != "main.go" { + t.Errorf("filePath: want main.go, got %s", r.FilePath) + } + if r.LineStart != 11 { // 0-indexed → 1-indexed + t.Errorf("lineStart: want 11, got %d", r.LineStart) + } + if r.LineEnd != 13 { + t.Errorf("lineEnd: want 13, got %d", r.LineEnd) + } + if r.Content != "func hello() error" { + t.Errorf("content: want 'func hello() error', got %q", r.Content) + } + if r.Language != "Go" { + t.Errorf("language: want Go, got %s", r.Language) + } +} + +func TestParseAstGrepOutput_MultipleMatches(t *testing.T) { + lines := `{"text":"func a()","range":{"start":{"line":0,"column":0},"end":{"line":2,"column":1}},"file":"/repo/a.go","language":"Go"} +{"text":"func b()","range":{"start":{"line":5,"column":0},"end":{"line":7,"column":1}},"file":"/repo/b.go","language":"Go"} +{"text":"func c()","range":{"start":{"line":10,"column":0},"end":{"line":12,"column":1}},"file":"/repo/sub/c.go","language":"Go"}` + + results, err := parseAstGrepOutput([]byte(lines), "/repo") + if err != nil { + t.Fatal(err) + } + if len(results) != 3 { + t.Fatalf("want 3 results, got %d", len(results)) + } + + if results[0].FilePath != "a.go" { + t.Errorf("result[0] filePath: want a.go, got %s", results[0].FilePath) + } + if results[1].FilePath != "b.go" { + t.Errorf("result[1] filePath: want b.go, got %s", results[1].FilePath) + } + if results[2].FilePath != "sub/c.go" { + t.Errorf("result[2] filePath: want sub/c.go, got %s", results[2].FilePath) + } +} + +func TestParseAstGrepOutput_InvalidJSON(t *testing.T) { + _, err := parseAstGrepOutput([]byte("not json"), "/tmp/repo") + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestParseAstGrepOutput_BlankLines(t *testing.T) { + input := "\n\n" + `{"text":"func a()","range":{"start":{"line":0,"column":0},"end":{"line":0,"column":10}},"file":"/repo/a.go","language":"Go"}` + "\n\n" + + results, err := parseAstGrepOutput([]byte(input), "/repo") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } +} + +func TestParseAstGrepOutput_FilePathStripping(t *testing.T) { + // Test with trailing slash in repoPath + jsonLine := `{"text":"x","range":{"start":{"line":0,"column":0},"end":{"line":0,"column":1}},"file":"/data/repos/myrepo/src/main.go","language":"Go"}` + + results, err := parseAstGrepOutput([]byte(jsonLine), "/data/repos/myrepo") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("want 1 result, got %d", len(results)) + } + if results[0].FilePath != "src/main.go" { + t.Errorf("filePath: want src/main.go, got %s", results[0].FilePath) + } +} + +func TestParseAstGrepOutput_DifferentLanguages(t *testing.T) { + lines := `{"text":"def hello():","range":{"start":{"line":0,"column":0},"end":{"line":1,"column":8}},"file":"/repo/hello.py","language":"Python"} +{"text":"fn main()","range":{"start":{"line":0,"column":0},"end":{"line":3,"column":1}},"file":"/repo/main.rs","language":"Rust"}` + + results, err := parseAstGrepOutput([]byte(lines), "/repo") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("want 2 results, got %d", len(results)) + } + if results[0].Language != "Python" { + t.Errorf("result[0] language: want Python, got %s", results[0].Language) + } + if results[1].Language != "Rust" { + t.Errorf("result[1] language: want Rust, got %s", results[1].Language) + } +} + +// --- SupportedLanguages tests --- + +func TestSupportedLanguages(t *testing.T) { + langs := SupportedLanguages() + if len(langs) == 0 { + t.Fatal("expected non-empty language list") + } + + // Check required languages are present + required := []string{"go", "python", "javascript", "typescript", "rust", "java"} + for _, r := range required { + found := false + for _, l := range langs { + if l == r { + found = true + break + } + } + if !found { + t.Errorf("expected %q in supported languages", r) + } + } +} + +// --- AstSearcher validation tests (using in-memory DB) --- + +func setupTestAstSearcher(t *testing.T) (*AstSearcher, *storage.RepoStore) { + t.Helper() + + dir := t.TempDir() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: filepath.Join(dir, "test.db"), + DataDir: dir, + } + mgr, err := storage.NewManager(cfg) + if err != nil { + t.Fatal(err) + } + if err := mgr.Initialize(); err != nil { + t.Fatal(err) + } + + repoStore := storage.NewRepoStore(mgr.DB()) + return NewAstSearcher(repoStore), repoStore +} + +func TestAstSearcher_EmptyPattern(t *testing.T) { + s, _ := setupTestAstSearcher(t) + _, err := s.Search("", "test-repo", "") + if err == nil { + t.Error("expected error for empty pattern") + } +} + +func TestAstSearcher_RepoNotFound(t *testing.T) { + s, _ := setupTestAstSearcher(t) + _, err := s.Search("func $NAME()", "nonexistent", "") + if err == nil { + t.Error("expected error for missing repo") + } +} + +func TestAstSearcher_RepoNotReady(t *testing.T) { + s, repoStore := setupTestAstSearcher(t) + + repo := &storage.Repo{ + Name: "cloning-repo", + URL: "https://example.com/cloning", + Branch: "main", + Status: storage.RepoStatusCloning, + LocalPath: "/tmp/cloning", + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + _, err := s.Search("func $NAME()", "cloning-repo", "") + if err == nil { + t.Error("expected error for repo in cloning state") + } +} + +func TestAstSearcher_AcceptsClonedRepo(t *testing.T) { + s, repoStore := setupTestAstSearcher(t) + + now := time.Now() + repo := &storage.Repo{ + Name: "cloned-repo", + URL: "https://example.com/cloned", + Branch: "main", + Status: storage.RepoStatusCloned, + LocalPath: "/tmp/nonexistent-for-test", + LastSynced: &now, + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + // This will fail at the ast-grep exec level (not found or bad path), not at validation + _, err := s.Search("func $NAME()", "cloned-repo", "") + if err == nil { + // ast-grep probably not installed in test env — that's fine + return + } + // Acceptable errors: binary not found or exec failure on nonexistent path + // Unacceptable: validation errors about repo status + if err.Error() == "repo cloned-repo is not ready (status: cloned)" { + t.Error("should accept cloned repos for ast search") + } +} + +func TestAstSearcher_AcceptsIndexedRepo(t *testing.T) { + s, repoStore := setupTestAstSearcher(t) + + now := time.Now() + repo := &storage.Repo{ + Name: "indexed-repo", + URL: "https://example.com/indexed", + Branch: "main", + Status: storage.RepoStatusIndexed, + LocalPath: "/tmp/nonexistent-for-test", + LastSynced: &now, + LastIndexed: &now, + } + if err := repoStore.Create(repo); err != nil { + t.Fatal(err) + } + + _, err := s.Search("func $NAME()", "indexed-repo", "") + if err == nil { + return + } + if err.Error() == "repo indexed-repo is not ready (status: indexed)" { + t.Error("should accept indexed repos for ast search") + } +} + +// --- isExecNotFound tests --- + +func TestIsExecNotFound(t *testing.T) { + tests := []struct { + msg string + want bool + }{ + {"executable file not found in $PATH", true}, + {"exec: \"ast-grep\": executable file not found in $PATH", true}, + {"some other error", false}, + {"", false}, + } + for _, tt := range tests { + got := isExecNotFound(errors.New(tt.msg)) + if got != tt.want { + t.Errorf("isExecNotFound(%q) = %v, want %v", tt.msg, got, tt.want) + } + } +} diff --git a/go/plugins/gitrepo-mcp/internal/server/mcp.go b/go/plugins/gitrepo-mcp/internal/server/mcp.go new file mode 100644 index 000000000..af7996519 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/server/mcp.go @@ -0,0 +1,537 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/indexer" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/repo" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/search" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MCPServer exposes gitrepo-mcp functionality via MCP protocol. +type MCPServer struct { + repoStore *storage.RepoStore + repoManager *repo.Manager + indexer *indexer.Indexer + searcher *search.Searcher + astSearcher *search.AstSearcher + reposDir string + server *mcpsdk.Server + httpHandler *mcpsdk.StreamableHTTPHandler +} + +// --- Input/Output types --- + +type AddRepoInput struct { + Name string `json:"name" jsonschema:"repository name (short identifier)"` + URL string `json:"url" jsonschema:"git clone URL"` + Branch string `json:"branch,omitempty" jsonschema:"git branch (default: main)"` +} + +type AddRepoOutput struct { + Repo storage.Repo `json:"repo"` +} + +type ListReposInput struct{} + +type ListReposOutput struct { + Repos []storage.Repo `json:"repos"` +} + +type RemoveRepoInput struct { + Name string `json:"name" jsonschema:"repository name to remove"` +} + +type RemoveRepoOutput struct { + Removed bool `json:"removed"` +} + +type SyncRepoInput struct { + Name string `json:"name" jsonschema:"repository name to sync"` +} + +type SyncRepoOutput struct { + Repo storage.Repo `json:"repo"` +} + +type IndexRepoInput struct { + Name string `json:"name" jsonschema:"repository name to index"` +} + +type IndexRepoOutput struct { + Repo storage.Repo `json:"repo"` +} + +type SearchCodeInput struct { + Query string `json:"query" jsonschema:"semantic search query"` + Repo string `json:"repo,omitempty" jsonschema:"repository name (omit to search all indexed repos)"` + Limit int `json:"limit,omitempty" jsonschema:"max results (default 10)"` + ContextLines int `json:"contextLines,omitempty" jsonschema:"context lines before/after each match"` +} + +type SearchCodeOutput struct { + Results []search.SearchResult `json:"results"` +} + +type AstSearchInput struct { + Pattern string `json:"pattern" jsonschema:"ast-grep pattern (e.g. func $NAME($$$) error)"` + Repo string `json:"repo" jsonschema:"repository name"` + Language string `json:"language,omitempty" jsonschema:"language filter (e.g. go, python)"` +} + +type AstSearchOutput struct { + Results []search.AstSearchResult `json:"results"` +} + +type AstSearchLanguagesInput struct{} + +type AstSearchLanguagesOutput struct { + Languages []string `json:"languages"` +} + +type SyncAllInput struct{} + +type SyncAllOutput struct { + Results []repo.SyncResult `json:"results"` +} + +// NewMCPServer creates an MCP server with all tools registered. +func NewMCPServer( + repoStore *storage.RepoStore, + repoManager *repo.Manager, + idx *indexer.Indexer, + searcher *search.Searcher, + astSearcher *search.AstSearcher, + reposDir string, +) *MCPServer { + m := &MCPServer{ + repoStore: repoStore, + repoManager: repoManager, + indexer: idx, + searcher: searcher, + astSearcher: astSearcher, + reposDir: reposDir, + } + + impl := &mcpsdk.Implementation{ + Name: "gitrepo-mcp", + Version: "0.1.0", + } + srv := mcpsdk.NewServer(impl, nil) + m.server = srv + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "add_repo", + Description: "Register and clone a git repository. Returns immediately with status 'cloning'; poll with list_repos to check when clone completes.", + }, m.handleAddRepo) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "list_repos", + Description: "List all registered git repositories with their status, file count, and chunk count.", + }, m.handleListRepos) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "remove_repo", + Description: "Remove a git repository and all its indexed data.", + }, m.handleRemoveRepo) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "sync_repo", + Description: "Pull latest changes for a repository (git pull --ff-only).", + }, m.handleSyncRepo) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "index_repo", + Description: "Index a repository for semantic search. Starts indexing in the background; poll with list_repos to check when indexing completes.", + }, m.handleIndexRepo) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "search_code", + Description: "Semantic code search across indexed repositories. Returns ranked results with file paths, line ranges, scores, and optional context lines.", + }, m.handleSearchCode) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "ast_search", + Description: "Structural code search using ast-grep patterns (e.g. 'func $NAME($$$) error'). Requires ast-grep binary in PATH.", + }, m.handleAstSearch) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "ast_search_languages", + Description: "List programming languages supported by ast-grep structural search.", + }, m.handleAstSearchLanguages) + + mcpsdk.AddTool(srv, &mcpsdk.Tool{ + Name: "sync_all_repos", + Description: "Sync all repositories (git pull) and trigger re-indexing for previously indexed repos. Busy repos are skipped.", + }, m.handleSyncAll) + + m.httpHandler = mcpsdk.NewStreamableHTTPHandler( + func(*http.Request) *mcpsdk.Server { return srv }, + nil, + ) + + return m +} + +// Server returns the underlying MCP server (for stdio transport). +func (m *MCPServer) Server() *mcpsdk.Server { + return m.server +} + +// ServeHTTP implements http.Handler for the StreamableHTTP transport. +func (m *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.httpHandler.ServeHTTP(w, r) +} + +// --- Tool handlers --- + +func (m *MCPServer) handleAddRepo(_ context.Context, _ *mcpsdk.CallToolRequest, in AddRepoInput) (*mcpsdk.CallToolResult, AddRepoOutput, error) { + if in.Name == "" { + return mcpError("name is required"), AddRepoOutput{}, nil + } + if in.URL == "" { + return mcpError("url is required"), AddRepoOutput{}, nil + } + if in.Branch == "" { + in.Branch = "main" + } + + if existing, _ := m.repoStore.Get(in.Name); existing != nil { + return mcpError("repo %s already exists", in.Name), AddRepoOutput{}, nil + } + + localPath := filepath.Join(m.reposDir, in.Name) + repo := &storage.Repo{ + Name: in.Name, + URL: in.URL, + Branch: in.Branch, + Status: storage.RepoStatusCloning, + LocalPath: localPath, + } + + if err := m.repoStore.Create(repo); err != nil { + return mcpError("failed to create repo: %v", err), AddRepoOutput{}, nil + } + + go m.cloneBackground(in.Name, in.URL, in.Branch, localPath) + + out := AddRepoOutput{Repo: *repo} + return mcpText("Repo %s registered (status: cloning). Clone started in background.", in.Name), out, nil +} + +func (m *MCPServer) handleListRepos(_ context.Context, _ *mcpsdk.CallToolRequest, _ ListReposInput) (*mcpsdk.CallToolResult, ListReposOutput, error) { + repos, err := m.repoStore.List() + if err != nil { + return mcpError("failed to list repos: %v", err), ListReposOutput{}, nil + } + + out := ListReposOutput{Repos: repos} + + var sb strings.Builder + if len(repos) == 0 { + sb.WriteString("No repositories registered.") + } else { + for i, r := range repos { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(fmt.Sprintf("%s [%s] %s (branch: %s, files: %d, chunks: %d)", + r.Name, r.Status, r.URL, r.Branch, r.FileCount, r.ChunkCount)) + } + } + + return mcpText("%s", sb.String()), out, nil +} + +func (m *MCPServer) handleRemoveRepo(_ context.Context, _ *mcpsdk.CallToolRequest, in RemoveRepoInput) (*mcpsdk.CallToolResult, RemoveRepoOutput, error) { + if in.Name == "" { + return mcpError("name is required"), RemoveRepoOutput{}, nil + } + + repo, err := m.repoStore.Get(in.Name) + if err != nil { + return mcpError("repo %s not found", in.Name), RemoveRepoOutput{}, nil + } + + if repo.LocalPath != "" { + _ = os.RemoveAll(repo.LocalPath) + } + + if err := m.repoStore.Delete(in.Name); err != nil { + return mcpError("failed to delete repo: %v", err), RemoveRepoOutput{}, nil + } + + return mcpText("Repo %s removed.", in.Name), RemoveRepoOutput{Removed: true}, nil +} + +func (m *MCPServer) handleSyncRepo(_ context.Context, _ *mcpsdk.CallToolRequest, in SyncRepoInput) (*mcpsdk.CallToolResult, SyncRepoOutput, error) { + if in.Name == "" { + return mcpError("name is required"), SyncRepoOutput{}, nil + } + + syncedRepo, err := m.repoManager.Sync(in.Name) + if err != nil { + return mcpError("%s", err), SyncRepoOutput{}, nil + } + + // Trigger background re-index if repo was indexed + reindexing := false + if syncedRepo.Status == storage.RepoStatusIndexed { + reindexing = true + go func() { + if err := m.indexer.Index(in.Name); err != nil { + log.Printf("background re-index of repo %s failed: %v", in.Name, err) + } + }() + } + + msg := fmt.Sprintf("Repo %s synced.", in.Name) + if reindexing { + msg = fmt.Sprintf("Repo %s synced. Re-indexing started in background.", in.Name) + } + return mcpText("%s", msg), SyncRepoOutput{Repo: *syncedRepo}, nil +} + +func (m *MCPServer) handleSyncAll(_ context.Context, _ *mcpsdk.CallToolRequest, _ SyncAllInput) (*mcpsdk.CallToolResult, SyncAllOutput, error) { + reindexFn := func(name string) error { + go func() { + if err := m.indexer.Index(name); err != nil { + log.Printf("background re-index of repo %s failed: %v", name, err) + } + }() + return nil + } + + results, err := m.repoManager.SyncAll(reindexFn) + if err != nil { + return mcpError("failed to sync repos: %v", err), SyncAllOutput{}, nil + } + if results == nil { + results = []repo.SyncResult{} + } + + var sb strings.Builder + synced, failed := 0, 0 + for _, r := range results { + if r.Synced { + synced++ + } + if r.Error != "" { + failed++ + } + } + sb.WriteString(fmt.Sprintf("Synced %d repo(s)", synced)) + if failed > 0 { + sb.WriteString(fmt.Sprintf(", %d skipped/failed", failed)) + } + + return mcpText("%s", sb.String()), SyncAllOutput{Results: results}, nil +} + +func (m *MCPServer) handleIndexRepo(_ context.Context, _ *mcpsdk.CallToolRequest, in IndexRepoInput) (*mcpsdk.CallToolResult, IndexRepoOutput, error) { + if in.Name == "" { + return mcpError("name is required"), IndexRepoOutput{}, nil + } + + repo, err := m.repoStore.Get(in.Name) + if err != nil { + return mcpError("repo %s not found", in.Name), IndexRepoOutput{}, nil + } + + if repo.Status == storage.RepoStatusCloning || repo.Status == storage.RepoStatusIndexing { + return mcpError("repo %s is busy (status: %s)", in.Name, repo.Status), IndexRepoOutput{}, nil + } + + go func() { + if err := m.indexer.Index(in.Name); err != nil { + log.Printf("background indexing of repo %s failed: %v", in.Name, err) + } + }() + + return mcpText("Indexing started for repo %s.", in.Name), IndexRepoOutput{Repo: *repo}, nil +} + +func (m *MCPServer) handleSearchCode(_ context.Context, _ *mcpsdk.CallToolRequest, in SearchCodeInput) (*mcpsdk.CallToolResult, SearchCodeOutput, error) { + if in.Query == "" { + return mcpError("query is required"), SearchCodeOutput{}, nil + } + if in.Limit <= 0 { + in.Limit = 10 + } + + var allResults []search.SearchResult + + if in.Repo != "" { + results, err := m.searcher.Search(in.Query, in.Repo, in.Limit, in.ContextLines) + if err != nil { + return mcpError("%s", err), SearchCodeOutput{}, nil + } + allResults = results + } else { + repos, err := m.repoStore.List() + if err != nil { + return mcpError("failed to list repos: %v", err), SearchCodeOutput{}, nil + } + + for _, repo := range repos { + if repo.Status != storage.RepoStatusIndexed { + continue + } + results, err := m.searcher.Search(in.Query, repo.Name, 0, in.ContextLines) + if err != nil { + log.Printf("search in repo %s failed: %v", repo.Name, err) + continue + } + allResults = append(allResults, results...) + } + + sort.Slice(allResults, func(i, j int) bool { + return allResults[i].Score > allResults[j].Score + }) + if len(allResults) > in.Limit { + allResults = allResults[:in.Limit] + } + } + + if allResults == nil { + allResults = []search.SearchResult{} + } + + out := SearchCodeOutput{Results: allResults} + + var sb strings.Builder + if len(allResults) == 0 { + sb.WriteString("No results found.") + } else { + for i, r := range allResults { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(fmt.Sprintf("[%.4f] %s:%s:%d-%d (%s)", + r.Score, r.Repo, r.FilePath, r.LineStart, r.LineEnd, r.ChunkType)) + if r.ChunkName != "" { + sb.WriteString(fmt.Sprintf(" %s", r.ChunkName)) + } + sb.WriteByte('\n') + sb.WriteString(r.Content) + } + } + + return mcpText("%s", sb.String()), out, nil +} + +func (m *MCPServer) handleAstSearch(_ context.Context, _ *mcpsdk.CallToolRequest, in AstSearchInput) (*mcpsdk.CallToolResult, AstSearchOutput, error) { + if in.Pattern == "" { + return mcpError("pattern is required"), AstSearchOutput{}, nil + } + if in.Repo == "" { + return mcpError("repo is required"), AstSearchOutput{}, nil + } + + results, err := m.astSearcher.Search(in.Pattern, in.Repo, in.Language) + if err != nil { + return mcpError("%s", err), AstSearchOutput{}, nil + } + + if results == nil { + results = []search.AstSearchResult{} + } + + out := AstSearchOutput{Results: results} + + var sb strings.Builder + if len(results) == 0 { + sb.WriteString("No matches found.") + } else { + for i, r := range results { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(fmt.Sprintf("%s:%d-%d [%s]\n%s", r.FilePath, r.LineStart, r.LineEnd, r.Language, r.Content)) + } + } + + return mcpText("%s", sb.String()), out, nil +} + +func (m *MCPServer) handleAstSearchLanguages(_ context.Context, _ *mcpsdk.CallToolRequest, _ AstSearchLanguagesInput) (*mcpsdk.CallToolResult, AstSearchLanguagesOutput, error) { + langs := search.SupportedLanguages() + out := AstSearchLanguagesOutput{Languages: langs} + return mcpText("Supported languages: %s", strings.Join(langs, ", ")), out, nil +} + +// --- Background operations --- + +func (m *MCPServer) cloneBackground(name, url, branch, localPath string) { + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + log.Printf("failed to create parent directory for %s: %v", name, err) + m.setRepoError(name, err) + return + } + + cmd := exec.Command("git", "clone", + "--branch", branch, + "--single-branch", + "--depth", "1", + url, localPath, + ) + if err := cmd.Run(); err != nil { + log.Printf("background clone of repo %s failed: %v", name, err) + m.setRepoError(name, err) + return + } + + repo, err := m.repoStore.Get(name) + if err != nil { + log.Printf("failed to get repo %s after clone: %v", name, err) + return + } + now := time.Now() + repo.Status = storage.RepoStatusCloned + repo.LastSynced = &now + repo.Error = nil + if err := m.repoStore.Update(repo); err != nil { + log.Printf("failed to update repo %s after clone: %v", name, err) + } +} + +func (m *MCPServer) setRepoError(name string, opErr error) { + repo, err := m.repoStore.Get(name) + if err != nil { + return + } + errMsg := opErr.Error() + repo.Status = storage.RepoStatusError + repo.Error = &errMsg + _ = m.repoStore.Update(repo) +} + +// --- Helpers --- + +func mcpError(format string, args ...interface{}) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: fmt.Sprintf(format, args...)}, + }, + IsError: true, + } +} + +func mcpText(format string, args ...interface{}) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: fmt.Sprintf(format, args...)}, + }, + } +} diff --git a/go/plugins/gitrepo-mcp/internal/server/mcp_test.go b/go/plugins/gitrepo-mcp/internal/server/mcp_test.go new file mode 100644 index 000000000..71e6418fa --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/server/mcp_test.go @@ -0,0 +1,496 @@ +package server + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/indexer" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/repo" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/search" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// setupMCPTest creates an MCP server and connects a client session via in-memory transport. +func setupMCPTest(t *testing.T) (*MCPServer, *mcpsdk.ClientSession) { + t.Helper() + + tmpDir := t.TempDir() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: filepath.Join(tmpDir, "test.db"), + DataDir: tmpDir, + } + mgr, err := storage.NewManager(cfg) + if err != nil { + t.Fatal(err) + } + if err := mgr.Initialize(); err != nil { + t.Fatal(err) + } + + repoStore := storage.NewRepoStore(mgr.DB()) + embStore := storage.NewEmbeddingStore(mgr.DB()) + emb := embedder.NewHashEmbedder(768) + + reposDir := filepath.Join(tmpDir, "repos") + repoMgr := repo.NewManager(repoStore, reposDir) + idx := indexer.NewIndexer(repoStore, embStore, emb) + s := search.NewSearcher(repoStore, embStore, emb) + astS := search.NewAstSearcher(repoStore) + + mcpSrv := NewMCPServer(repoStore, repoMgr, idx, s, astS, reposDir) + + ctx := context.Background() + t1, t2 := mcpsdk.NewInMemoryTransports() + if _, err := mcpSrv.Server().Connect(ctx, t1, nil); err != nil { + t.Fatal(err) + } + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "0.0.1"}, nil) + session, err := client.Connect(ctx, t2, nil) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { session.Close() }) + + return mcpSrv, session +} + +// callTool is a helper to call an MCP tool and return the result. +func callTool(t *testing.T, session *mcpsdk.ClientSession, name string, args map[string]any) *mcpsdk.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcpsdk.CallToolParams{ + Name: name, + Arguments: args, + }) + if err != nil { + t.Fatalf("CallTool(%s) failed: %v", name, err) + } + return result +} + +// callToolExpectError calls an MCP tool and expects an error (e.g., schema validation). +func callToolExpectError(t *testing.T, session *mcpsdk.ClientSession, name string, args map[string]any) { + t.Helper() + _, err := session.CallTool(context.Background(), &mcpsdk.CallToolParams{ + Name: name, + Arguments: args, + }) + if err == nil { + t.Errorf("CallTool(%s) expected error, got nil", name) + } +} + +// resultText extracts the text content from a CallToolResult. +func resultText(t *testing.T, result *mcpsdk.CallToolResult) string { + t.Helper() + if len(result.Content) == 0 { + return "" + } + tc, ok := result.Content[0].(*mcpsdk.TextContent) + if !ok { + t.Fatalf("expected TextContent, got %T", result.Content[0]) + } + return tc.Text +} + +// --- Tool registration --- + +func TestMCP_ToolsRegistered(t *testing.T) { + _, session := setupMCPTest(t) + + tools := map[string]bool{} + for tool, err := range session.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + tools[tool.Name] = true + } + + expected := []string{ + "add_repo", "list_repos", "remove_repo", "sync_repo", + "index_repo", "search_code", "ast_search", "ast_search_languages", + "sync_all_repos", + } + for _, name := range expected { + if !tools[name] { + t.Errorf("expected tool %s to be registered", name) + } + } + if len(tools) != len(expected) { + t.Errorf("expected %d tools, got %d", len(expected), len(tools)) + } +} + +// --- list_repos --- + +func TestMCP_ListRepos_Empty(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "list_repos", nil) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + text := resultText(t, result) + if text != "No repositories registered." { + t.Errorf("unexpected text: %s", text) + } +} + +func TestMCP_ListRepos_WithRepos(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "alpha", URL: "http://example.com/alpha.git", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: "/tmp/alpha", + }) + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "beta", URL: "http://example.com/beta.git", Branch: "dev", + Status: storage.RepoStatusIndexed, LocalPath: "/tmp/beta", FileCount: 10, ChunkCount: 50, + }) + + result := callTool(t, session, "list_repos", nil) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + + // Check structured output + if result.StructuredContent != nil { + data, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatalf("failed to marshal structured content: %v", err) + } + var out ListReposOutput + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("failed to unmarshal structured content: %v", err) + } + if len(out.Repos) != 2 { + t.Errorf("expected 2 repos in structured output, got %d", len(out.Repos)) + } + } +} + +// --- add_repo --- + +func TestMCP_AddRepo_MissingName(t *testing.T) { + _, session := setupMCPTest(t) + // SDK validates schema: name is required + callToolExpectError(t, session, "add_repo", map[string]any{ + "url": "http://example.com/repo.git", + }) +} + +func TestMCP_AddRepo_MissingURL(t *testing.T) { + _, session := setupMCPTest(t) + // SDK validates schema: url is required + callToolExpectError(t, session, "add_repo", map[string]any{ + "name": "test", + }) +} + +func TestMCP_AddRepo_Success(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "add_repo", map[string]any{ + "name": "test", + "url": "http://invalid-url/repo.git", + }) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + + text := resultText(t, result) + if text == "" { + t.Error("expected non-empty response") + } +} + +func TestMCP_AddRepo_DefaultBranch(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "add_repo", map[string]any{ + "name": "test", + "url": "http://invalid-url/repo.git", + }) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + + // Verify via list_repos + listResult := callTool(t, session, "list_repos", nil) + text := resultText(t, listResult) + if text == "" || text == "No repositories registered." { + t.Error("expected repos after add") + } +} + +func TestMCP_AddRepo_Duplicate(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "test", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: "/tmp/test", + }) + + result := callTool(t, session, "add_repo", map[string]any{ + "name": "test", + "url": "http://example.com/repo.git", + }) + if !result.IsError { + t.Error("expected error for duplicate repo") + } +} + +// --- remove_repo --- + +func TestMCP_RemoveRepo_Success(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "test", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: t.TempDir(), + }) + + result := callTool(t, session, "remove_repo", map[string]any{"name": "test"}) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + + // Verify removed + listResult := callTool(t, session, "list_repos", nil) + text := resultText(t, listResult) + if text != "No repositories registered." { + t.Errorf("expected empty list after remove, got: %s", text) + } +} + +func TestMCP_RemoveRepo_NotFound(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "remove_repo", map[string]any{"name": "nonexistent"}) + if !result.IsError { + t.Error("expected error for nonexistent repo") + } +} + +func TestMCP_RemoveRepo_MissingName(t *testing.T) { + _, session := setupMCPTest(t) + // SDK validates schema: name is required + callToolExpectError(t, session, "remove_repo", map[string]any{}) +} + +// --- sync_repo --- + +func TestMCP_SyncRepo_NotFound(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "sync_repo", map[string]any{"name": "nonexistent"}) + if !result.IsError { + t.Error("expected error for nonexistent repo") + } +} + +func TestMCP_SyncRepo_Busy(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "busy", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloning, LocalPath: "/tmp/busy", + }) + + result := callTool(t, session, "sync_repo", map[string]any{"name": "busy"}) + if !result.IsError { + t.Error("expected error for busy repo") + } +} + +// --- index_repo --- + +func TestMCP_IndexRepo_NotFound(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "index_repo", map[string]any{"name": "nonexistent"}) + if !result.IsError { + t.Error("expected error for nonexistent repo") + } +} + +func TestMCP_IndexRepo_Busy(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "busy", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusIndexing, LocalPath: "/tmp/busy", + }) + + result := callTool(t, session, "index_repo", map[string]any{"name": "busy"}) + if !result.IsError { + t.Error("expected error for busy repo") + } +} + +func TestMCP_IndexRepo_Success(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "ready", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: t.TempDir(), + }) + + result := callTool(t, session, "index_repo", map[string]any{"name": "ready"}) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } +} + +// --- search_code --- + +func TestMCP_SearchCode_EmptyQuery(t *testing.T) { + _, session := setupMCPTest(t) + // SDK validates schema: query is required + callToolExpectError(t, session, "search_code", map[string]any{}) +} + +func TestMCP_SearchCode_RepoNotFound(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "search_code", map[string]any{ + "query": "test", + "repo": "nonexistent", + }) + if !result.IsError { + t.Error("expected error for nonexistent repo") + } +} + +func TestMCP_SearchCode_RepoNotIndexed(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "test", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: "/tmp/test", + }) + + result := callTool(t, session, "search_code", map[string]any{ + "query": "hello", + "repo": "test", + }) + if !result.IsError { + t.Error("expected error for non-indexed repo") + } +} + +func TestMCP_SearchCode_NoIndexedRepos(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "search_code", map[string]any{ + "query": "test", + }) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + text := resultText(t, result) + if text != "No results found." { + t.Errorf("expected 'No results found.', got: %s", text) + } +} + +// --- ast_search --- + +func TestMCP_AstSearch_EmptyPattern(t *testing.T) { + _, session := setupMCPTest(t) + // SDK validates schema: pattern is required + callToolExpectError(t, session, "ast_search", map[string]any{ + "repo": "test", + }) +} + +func TestMCP_AstSearch_MissingRepo(t *testing.T) { + _, session := setupMCPTest(t) + // SDK validates schema: repo is required + callToolExpectError(t, session, "ast_search", map[string]any{ + "pattern": "func $NAME()", + }) +} + +func TestMCP_AstSearch_RepoNotFound(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "ast_search", map[string]any{ + "pattern": "func $NAME()", + "repo": "nonexistent", + }) + if !result.IsError { + t.Error("expected error for nonexistent repo") + } +} + +// --- ast_search_languages --- + +func TestMCP_AstSearchLanguages(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "ast_search_languages", nil) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + + text := resultText(t, result) + if text == "" { + t.Error("expected non-empty languages list") + } + + // Check structured output has languages + if result.StructuredContent != nil { + data, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatalf("failed to marshal structured content: %v", err) + } + var out AstSearchLanguagesOutput + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("failed to unmarshal structured content: %v", err) + } + if len(out.Languages) == 0 { + t.Error("expected non-empty languages in structured output") + } + } +} + +// --- sync_all_repos --- + +func TestMCP_SyncAll_Empty(t *testing.T) { + _, session := setupMCPTest(t) + + result := callTool(t, session, "sync_all_repos", nil) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + text := resultText(t, result) + if text != "Synced 0 repo(s)" { + t.Errorf("expected 'Synced 0 repo(s)', got: %s", text) + } +} + +func TestMCP_SyncAll_SkipsBusy(t *testing.T) { + mcpSrv, session := setupMCPTest(t) + + _ = mcpSrv.repoStore.Create(&storage.Repo{ + Name: "busy", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloning, LocalPath: "/tmp/busy", + }) + + result := callTool(t, session, "sync_all_repos", nil) + if result.IsError { + t.Errorf("unexpected error: %s", resultText(t, result)) + } + text := resultText(t, result) + if text != "Synced 0 repo(s), 1 skipped/failed" { + t.Errorf("unexpected text: %s", text) + } +} diff --git a/go/plugins/gitrepo-mcp/internal/server/rest.go b/go/plugins/gitrepo-mcp/internal/server/rest.go new file mode 100644 index 000000000..c16032d17 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/server/rest.go @@ -0,0 +1,490 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/indexer" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/repo" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/search" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +// Server serves the REST API for gitrepo-mcp. +type Server struct { + repoStore *storage.RepoStore + repoManager *repo.Manager + indexer *indexer.Indexer + searcher *search.Searcher + astSearcher *search.AstSearcher + reposDir string +} + +// NewServer creates a REST API server. +func NewServer( + repoStore *storage.RepoStore, + repoManager *repo.Manager, + idx *indexer.Indexer, + searcher *search.Searcher, + astSearcher *search.AstSearcher, + reposDir string, +) *Server { + return &Server{ + repoStore: repoStore, + repoManager: repoManager, + indexer: idx, + searcher: searcher, + astSearcher: astSearcher, + reposDir: reposDir, + } +} + +// Handler returns the HTTP handler with all routes registered. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Health check + mux.HandleFunc("GET /health", s.handleHealth) + + // Repo CRUD + mux.HandleFunc("GET /api/repos", s.handleListRepos) + mux.HandleFunc("POST /api/repos", s.handleAddRepo) + mux.HandleFunc("GET /api/repos/{name}", s.handleGetRepo) + mux.HandleFunc("DELETE /api/repos/{name}", s.handleDeleteRepo) + + // Operations + mux.HandleFunc("POST /api/repos/{name}/sync", s.handleSyncRepo) + mux.HandleFunc("POST /api/repos/{name}/index", s.handleIndexRepo) + mux.HandleFunc("POST /api/sync-all", s.handleSyncAll) + + // Search + mux.HandleFunc("POST /api/repos/{name}/search", s.handleSearchRepo) + mux.HandleFunc("POST /api/search", s.handleSearchAll) + + // ast-grep structural search + mux.HandleFunc("POST /api/repos/{name}/ast-search", s.handleAstSearch) + mux.HandleFunc("GET /api/ast-search/languages", s.handleAstSearchLanguages) + + return withLogging(mux) +} + +// --- Request/Response types --- + +type addRepoRequest struct { + Name string `json:"name"` + URL string `json:"url"` + Branch string `json:"branch,omitempty"` +} + +type searchRequest struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + ContextLines int `json:"contextLines,omitempty"` +} + +type astSearchRequest struct { + Pattern string `json:"pattern"` + Language string `json:"language,omitempty"` +} + +type errorResponse struct { + Error string `json:"error"` +} + +type listReposResponse struct { + Repos []storage.Repo `json:"repos"` +} + +type searchResponse struct { + Results []search.SearchResult `json:"results"` +} + +type astSearchResponse struct { + Results []search.AstSearchResult `json:"results"` +} + +type languagesResponse struct { + Languages []string `json:"languages"` +} + +type syncAllResponse struct { + Results []repo.SyncResult `json:"results"` +} + +// --- Handlers --- + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleListRepos(w http.ResponseWriter, _ *http.Request) { + repos, err := s.repoStore.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list repos: %v", err) + return + } + writeJSON(w, http.StatusOK, listReposResponse{Repos: repos}) +} + +func (s *Server) handleAddRepo(w http.ResponseWriter, r *http.Request) { + var req addRepoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: %v", err) + return + } + + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.URL == "" { + writeError(w, http.StatusBadRequest, "url is required") + return + } + if req.Branch == "" { + req.Branch = "main" + } + + if existing, _ := s.repoStore.Get(req.Name); existing != nil { + writeError(w, http.StatusConflict, "repo %s already exists", req.Name) + return + } + + localPath := filepath.Join(s.reposDir, req.Name) + repoEntry := &storage.Repo{ + Name: req.Name, + URL: req.URL, + Branch: req.Branch, + Status: storage.RepoStatusCloning, + LocalPath: localPath, + } + + if err := s.repoStore.Create(repoEntry); err != nil { + writeError(w, http.StatusInternalServerError, "failed to create repo: %v", err) + return + } + + go s.cloneRepoBackground(req.Name, req.URL, req.Branch, localPath) + + writeJSON(w, http.StatusAccepted, repoEntry) +} + +func (s *Server) handleGetRepo(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + repo, err := s.repoStore.Get(name) + if err != nil { + writeError(w, http.StatusNotFound, "repo %s not found", name) + return + } + + writeJSON(w, http.StatusOK, repo) +} + +func (s *Server) handleDeleteRepo(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + repo, err := s.repoStore.Get(name) + if err != nil { + writeError(w, http.StatusNotFound, "repo %s not found", name) + return + } + + if repo.LocalPath != "" { + _ = os.RemoveAll(repo.LocalPath) + } + + if err := s.repoStore.Delete(name); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete repo: %v", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleSyncRepo(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + syncedRepo, err := s.repoManager.Sync(name) + if err != nil { + if strings.Contains(err.Error(), "not found") { + writeError(w, http.StatusNotFound, "repo %s not found", name) + return + } + if strings.Contains(err.Error(), "busy") { + writeError(w, http.StatusConflict, "%s", err) + return + } + writeError(w, http.StatusInternalServerError, "%s", err) + return + } + + // Trigger background re-index if repo was indexed + if syncedRepo.Status == storage.RepoStatusIndexed { + go func() { + if err := s.indexer.Index(name); err != nil { + log.Printf("background re-index of repo %s failed: %v", name, err) + } + }() + } + + writeJSON(w, http.StatusOK, syncedRepo) +} + +func (s *Server) handleSyncAll(w http.ResponseWriter, _ *http.Request) { + reindexFn := func(name string) error { + go func() { + if err := s.indexer.Index(name); err != nil { + log.Printf("background re-index of repo %s failed: %v", name, err) + } + }() + return nil + } + + results, err := s.repoManager.SyncAll(reindexFn) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to sync repos: %v", err) + return + } + if results == nil { + results = []repo.SyncResult{} + } + + writeJSON(w, http.StatusOK, syncAllResponse{Results: results}) +} + +func (s *Server) handleIndexRepo(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + repo, err := s.repoStore.Get(name) + if err != nil { + writeError(w, http.StatusNotFound, "repo %s not found", name) + return + } + + if repo.Status == storage.RepoStatusCloning || repo.Status == storage.RepoStatusIndexing { + writeError(w, http.StatusConflict, "repo %s is busy (status: %s)", name, repo.Status) + return + } + + go func() { + if err := s.indexer.Index(name); err != nil { + log.Printf("background indexing of repo %s failed: %v", name, err) + } + }() + + writeJSON(w, http.StatusAccepted, repo) +} + +func (s *Server) handleSearchRepo(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + var req searchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: %v", err) + return + } + + if req.Query == "" { + writeError(w, http.StatusBadRequest, "query is required") + return + } + + results, err := s.searcher.Search(req.Query, name, req.Limit, req.ContextLines) + if err != nil { + if strings.Contains(err.Error(), "not found") { + writeError(w, http.StatusNotFound, "%s", err) + return + } + if strings.Contains(err.Error(), "not indexed") { + writeError(w, http.StatusConflict, "%s", err) + return + } + writeError(w, http.StatusInternalServerError, "search failed: %v", err) + return + } + + if results == nil { + results = []search.SearchResult{} + } + + writeJSON(w, http.StatusOK, searchResponse{Results: results}) +} + +func (s *Server) handleSearchAll(w http.ResponseWriter, r *http.Request) { + var req searchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: %v", err) + return + } + + if req.Query == "" { + writeError(w, http.StatusBadRequest, "query is required") + return + } + + repos, err := s.repoStore.List() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list repos: %v", err) + return + } + + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + var allResults []search.SearchResult + for _, repo := range repos { + if repo.Status != storage.RepoStatusIndexed { + continue + } + results, err := s.searcher.Search(req.Query, repo.Name, 0, req.ContextLines) + if err != nil { + log.Printf("search in repo %s failed: %v", repo.Name, err) + continue + } + allResults = append(allResults, results...) + } + + sort.Slice(allResults, func(i, j int) bool { + return allResults[i].Score > allResults[j].Score + }) + + if len(allResults) > limit { + allResults = allResults[:limit] + } + + if allResults == nil { + allResults = []search.SearchResult{} + } + + writeJSON(w, http.StatusOK, searchResponse{Results: allResults}) +} + +func (s *Server) handleAstSearch(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + var req astSearchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: %v", err) + return + } + + if req.Pattern == "" { + writeError(w, http.StatusBadRequest, "pattern is required") + return + } + + results, err := s.astSearcher.Search(req.Pattern, name, req.Language) + if err != nil { + if strings.Contains(err.Error(), "not found") { + writeError(w, http.StatusNotFound, "%s", err) + return + } + if strings.Contains(err.Error(), "not ready") { + writeError(w, http.StatusConflict, "%s", err) + return + } + writeError(w, http.StatusInternalServerError, "ast-search failed: %v", err) + return + } + + if results == nil { + results = []search.AstSearchResult{} + } + + writeJSON(w, http.StatusOK, astSearchResponse{Results: results}) +} + +func (s *Server) handleAstSearchLanguages(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, languagesResponse{Languages: search.SupportedLanguages()}) +} + +// --- Background operations --- + +func (s *Server) cloneRepoBackground(name, url, branch, localPath string) { + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + log.Printf("failed to create parent directory for %s: %v", name, err) + s.setRepoError(name, err) + return + } + + cmd := exec.Command("git", "clone", + "--branch", branch, + "--single-branch", + "--depth", "1", + url, localPath, + ) + if err := cmd.Run(); err != nil { + log.Printf("background clone of repo %s failed: %v", name, err) + s.setRepoError(name, err) + return + } + + repo, err := s.repoStore.Get(name) + if err != nil { + log.Printf("failed to get repo %s after clone: %v", name, err) + return + } + now := time.Now() + repo.Status = storage.RepoStatusCloned + repo.LastSynced = &now + repo.Error = nil + if err := s.repoStore.Update(repo); err != nil { + log.Printf("failed to update repo %s after clone: %v", name, err) + } +} + +func (s *Server) setRepoError(name string, opErr error) { + repo, err := s.repoStore.Get(name) + if err != nil { + return + } + errMsg := opErr.Error() + repo.Status = storage.RepoStatusError + repo.Error = &errMsg + _ = s.repoStore.Update(repo) +} + +// --- Helpers --- + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + writeJSON(w, status, errorResponse{Error: msg}) +} + +// --- Middleware --- + +func withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start).Round(time.Millisecond)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} diff --git a/go/plugins/gitrepo-mcp/internal/server/rest_test.go b/go/plugins/gitrepo-mcp/internal/server/rest_test.go new file mode 100644 index 000000000..7cbb5a114 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/server/rest_test.go @@ -0,0 +1,550 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/indexer" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/repo" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/search" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" +) + +func setupTestServer(t *testing.T) (*Server, *httptest.Server) { + t.Helper() + + tmpDir := t.TempDir() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: filepath.Join(tmpDir, "test.db"), + DataDir: tmpDir, + } + mgr, err := storage.NewManager(cfg) + if err != nil { + t.Fatal(err) + } + if err := mgr.Initialize(); err != nil { + t.Fatal(err) + } + + repoStore := storage.NewRepoStore(mgr.DB()) + embStore := storage.NewEmbeddingStore(mgr.DB()) + emb := embedder.NewHashEmbedder(768) + + reposDir := filepath.Join(tmpDir, "repos") + repoMgr := repo.NewManager(repoStore, reposDir) + idx := indexer.NewIndexer(repoStore, embStore, emb) + s := search.NewSearcher(repoStore, embStore, emb) + astS := search.NewAstSearcher(repoStore) + + srv := NewServer(repoStore, repoMgr, idx, s, astS, reposDir) + ts := httptest.NewServer(srv.Handler()) + + return srv, ts +} + +func doRequest(t *testing.T, method, url string, body interface{}) *http.Response { + t.Helper() + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + bodyReader = bytes.NewReader(data) + } + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func decodeJSON(t *testing.T, resp *http.Response, v interface{}) { + t.Helper() + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatalf("failed to decode response: %v", err) + } +} + +// --- Health --- + +func TestHealth(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "GET", ts.URL+"/health", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body map[string]string + decodeJSON(t, resp, &body) + if body["status"] != "ok" { + t.Errorf("expected status=ok, got %s", body["status"]) + } +} + +// --- List repos --- + +func TestListRepos_Empty(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "GET", ts.URL+"/api/repos", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body listReposResponse + decodeJSON(t, resp, &body) + if len(body.Repos) != 0 { + t.Errorf("expected 0 repos, got %d", len(body.Repos)) + } +} + +func TestListRepos_WithRepos(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "alpha", URL: "http://example.com/alpha.git", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: "/tmp/alpha", + }) + _ = srv.repoStore.Create(&storage.Repo{ + Name: "beta", URL: "http://example.com/beta.git", Branch: "dev", + Status: storage.RepoStatusIndexed, LocalPath: "/tmp/beta", + }) + + resp := doRequest(t, "GET", ts.URL+"/api/repos", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body listReposResponse + decodeJSON(t, resp, &body) + if len(body.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(body.Repos)) + } + if body.Repos[0].Name != "alpha" { + t.Errorf("expected first repo name=alpha, got %s", body.Repos[0].Name) + } +} + +// --- Add repo --- + +func TestAddRepo_MissingName(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos", map[string]string{"url": "http://example.com/repo.git"}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } +} + +func TestAddRepo_MissingURL(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos", map[string]string{"name": "test"}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } +} + +func TestAddRepo_InvalidJSON(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + req, _ := http.NewRequest("POST", ts.URL+"/api/repos", bytes.NewBufferString("not json")) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAddRepo_Accepted(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos", addRepoRequest{ + Name: "test", URL: "http://invalid-url/repo.git", Branch: "main", + }) + if resp.StatusCode != http.StatusAccepted { + t.Errorf("expected 202, got %d", resp.StatusCode) + } + + var repo storage.Repo + decodeJSON(t, resp, &repo) + if repo.Name != "test" { + t.Errorf("expected name=test, got %s", repo.Name) + } + if repo.Status != storage.RepoStatusCloning { + t.Errorf("expected status=cloning, got %s", repo.Status) + } +} + +func TestAddRepo_DefaultBranch(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos", map[string]string{ + "name": "test", "url": "http://example.com/repo.git", + }) + if resp.StatusCode != http.StatusAccepted { + t.Errorf("expected 202, got %d", resp.StatusCode) + } + + var repo storage.Repo + decodeJSON(t, resp, &repo) + if repo.Branch != "main" { + t.Errorf("expected branch=main, got %s", repo.Branch) + } +} + +func TestAddRepo_Duplicate(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "test", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: "/tmp/test", + }) + + resp := doRequest(t, "POST", ts.URL+"/api/repos", addRepoRequest{ + Name: "test", URL: "http://example.com/repo.git", + }) + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected 409, got %d", resp.StatusCode) + } +} + +// --- Get repo --- + +func TestGetRepo_Found(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "kagent", URL: "http://example.com/kagent.git", Branch: "main", + Status: storage.RepoStatusIndexed, LocalPath: "/tmp/kagent", FileCount: 42, ChunkCount: 100, + }) + + resp := doRequest(t, "GET", ts.URL+"/api/repos/kagent", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var repo storage.Repo + decodeJSON(t, resp, &repo) + if repo.Name != "kagent" { + t.Errorf("expected name=kagent, got %s", repo.Name) + } + if repo.FileCount != 42 { + t.Errorf("expected fileCount=42, got %d", repo.FileCount) + } +} + +func TestGetRepo_NotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "GET", ts.URL+"/api/repos/nonexistent", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +// --- Delete repo --- + +func TestDeleteRepo_Success(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "test", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: t.TempDir(), + }) + + resp := doRequest(t, "DELETE", ts.URL+"/api/repos/test", nil) + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Verify deleted + resp2 := doRequest(t, "GET", ts.URL+"/api/repos/test", nil) + if resp2.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 after delete, got %d", resp2.StatusCode) + } + resp2.Body.Close() +} + +func TestDeleteRepo_NotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "DELETE", ts.URL+"/api/repos/nonexistent", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +// --- Sync repo --- + +func TestSyncRepo_NotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos/nonexistent/sync", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestSyncRepo_Busy(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "busy", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloning, LocalPath: "/tmp/busy", + }) + + resp := doRequest(t, "POST", ts.URL+"/api/repos/busy/sync", nil) + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected 409, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +// --- Index repo --- + +func TestIndexRepo_NotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos/nonexistent/index", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestIndexRepo_Busy(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "busy", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusIndexing, LocalPath: "/tmp/busy", + }) + + resp := doRequest(t, "POST", ts.URL+"/api/repos/busy/index", nil) + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected 409, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestIndexRepo_Accepted(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "ready", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: t.TempDir(), + }) + + resp := doRequest(t, "POST", ts.URL+"/api/repos/ready/index", nil) + if resp.StatusCode != http.StatusAccepted { + t.Errorf("expected 202, got %d", resp.StatusCode) + } + + var repo storage.Repo + decodeJSON(t, resp, &repo) + if repo.Name != "ready" { + t.Errorf("expected name=ready, got %s", repo.Name) + } + + // Give goroutine time to complete + time.Sleep(100 * time.Millisecond) +} + +// --- Search repo --- + +func TestSearchRepo_EmptyQuery(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos/test/search", searchRequest{}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestSearchRepo_RepoNotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos/nonexistent/search", searchRequest{Query: "test"}) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestSearchRepo_RepoNotIndexed(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "test", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloned, LocalPath: "/tmp/test", + }) + + resp := doRequest(t, "POST", ts.URL+"/api/repos/test/search", searchRequest{Query: "hello"}) + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected 409, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +// --- Search all --- + +func TestSearchAll_EmptyQuery(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/search", searchRequest{}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestSearchAll_NoIndexedRepos(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/search", searchRequest{Query: "test"}) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body searchResponse + decodeJSON(t, resp, &body) + if len(body.Results) != 0 { + t.Errorf("expected 0 results, got %d", len(body.Results)) + } +} + +// --- ast-grep --- + +func TestAstSearchLanguages(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "GET", ts.URL+"/api/ast-search/languages", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body languagesResponse + decodeJSON(t, resp, &body) + if len(body.Languages) == 0 { + t.Error("expected non-empty languages list") + } +} + +func TestAstSearch_EmptyPattern(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos/test/ast-search", astSearchRequest{}) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestAstSearch_RepoNotFound(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/repos/nonexistent/ast-search", astSearchRequest{Pattern: "func $NAME()"}) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +// --- Sync-all --- + +func TestSyncAll_Empty(t *testing.T) { + _, ts := setupTestServer(t) + defer ts.Close() + + resp := doRequest(t, "POST", ts.URL+"/api/sync-all", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body syncAllResponse + decodeJSON(t, resp, &body) + if len(body.Results) != 0 { + t.Errorf("expected 0 results, got %d", len(body.Results)) + } +} + +func TestSyncAll_SkipsBusy(t *testing.T) { + srv, ts := setupTestServer(t) + defer ts.Close() + + _ = srv.repoStore.Create(&storage.Repo{ + Name: "busy", URL: "http://example.com", Branch: "main", + Status: storage.RepoStatusCloning, LocalPath: "/tmp/busy", + }) + + resp := doRequest(t, "POST", ts.URL+"/api/sync-all", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body syncAllResponse + decodeJSON(t, resp, &body) + if len(body.Results) != 1 { + t.Fatalf("expected 1 result, got %d", len(body.Results)) + } + if body.Results[0].Synced { + t.Error("expected busy repo to not be synced") + } + if body.Results[0].Error == "" { + t.Error("expected error message for busy repo") + } +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/db.go b/go/plugins/gitrepo-mcp/internal/storage/db.go new file mode 100644 index 000000000..11bbeaa5f --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/db.go @@ -0,0 +1,62 @@ +package storage + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Manager handles database connection and initialization. +type Manager struct { + db *gorm.DB +} + +// NewManager creates a new database manager based on the provided config. +func NewManager(cfg *config.Config) (*Manager, error) { + var db *gorm.DB + var err error + + gormCfg := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + TranslateError: true, + } + + switch cfg.DBType { + case config.DBTypeSQLite: + // Ensure parent directory exists + dir := filepath.Dir(cfg.DBPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err) + } + db, err = gorm.Open(sqlite.Open(cfg.DBPath+"?_pragma=foreign_keys(1)"), gormCfg) + case config.DBTypePostgres: + db, err = gorm.Open(postgres.Open(cfg.DBURL), gormCfg) + default: + return nil, fmt.Errorf("invalid database type: %s", cfg.DBType) + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + return &Manager{db: db}, nil +} + +// Initialize runs AutoMigrate for all models. +func (m *Manager) Initialize() error { + if err := m.db.AutoMigrate(&Repo{}, &Collection{}, &Chunk{}); err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + return nil +} + +// DB returns the underlying *gorm.DB instance. +func (m *Manager) DB() *gorm.DB { + return m.db +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/db_test.go b/go/plugins/gitrepo-mcp/internal/storage/db_test.go new file mode 100644 index 000000000..126f3c049 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/db_test.go @@ -0,0 +1,53 @@ +package storage + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestManager(t *testing.T) *Manager { + t.Helper() + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: ":memory:", + } + mgr, err := NewManager(cfg) + require.NoError(t, err) + require.NoError(t, mgr.Initialize()) + return mgr +} + +func TestNewManager_SQLite(t *testing.T) { + mgr := newTestManager(t) + assert.NotNil(t, mgr.DB()) +} + +func TestNewManager_InvalidDBType(t *testing.T) { + cfg := &config.Config{ + DBType: "invalid", + } + _, err := NewManager(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid database type") +} + +func TestInitialize_CreatesTables(t *testing.T) { + mgr := newTestManager(t) + db := mgr.DB() + + // Verify tables exist by querying them + var repoCount int64 + require.NoError(t, db.Model(&Repo{}).Count(&repoCount).Error) + assert.Equal(t, int64(0), repoCount) + + var collCount int64 + require.NoError(t, db.Model(&Collection{}).Count(&collCount).Error) + assert.Equal(t, int64(0), collCount) + + var chunkCount int64 + require.NoError(t, db.Model(&Chunk{}).Count(&chunkCount).Error) + assert.Equal(t, int64(0), chunkCount) +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/embeddings.go b/go/plugins/gitrepo-mcp/internal/storage/embeddings.go new file mode 100644 index 000000000..2e51f897a --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/embeddings.go @@ -0,0 +1,113 @@ +package storage + +import ( + "encoding/binary" + "fmt" + "math" + + "gorm.io/gorm" +) + +// EmbeddingStore provides CRUD operations for collections and chunks. +type EmbeddingStore struct { + db *gorm.DB +} + +// NewEmbeddingStore creates a new EmbeddingStore. +func NewEmbeddingStore(db *gorm.DB) *EmbeddingStore { + return &EmbeddingStore{db: db} +} + +// GetOrCreateCollection returns the collection for a repo, creating it if needed. +func (s *EmbeddingStore) GetOrCreateCollection(repoName, model string, dimensions int) (*Collection, error) { + var coll Collection + err := s.db.Where("repo_name = ?", repoName).First(&coll).Error + if err == nil { + return &coll, nil + } + if err != gorm.ErrRecordNotFound { + return nil, fmt.Errorf("failed to query collection for repo %s: %w", repoName, err) + } + + coll = Collection{ + RepoName: repoName, + Model: model, + Dimensions: dimensions, + } + if err := s.db.Create(&coll).Error; err != nil { + return nil, fmt.Errorf("failed to create collection for repo %s: %w", repoName, err) + } + return &coll, nil +} + +// GetChunksByCollection returns all chunks for a collection. +func (s *EmbeddingStore) GetChunksByCollection(collectionID uint) ([]Chunk, error) { + var chunks []Chunk + if err := s.db.Where("collection_id = ?", collectionID).Find(&chunks).Error; err != nil { + return nil, fmt.Errorf("failed to get chunks for collection %d: %w", collectionID, err) + } + return chunks, nil +} + +// ChunkExistsByHash checks if a chunk with the given content hash exists in the collection. +func (s *EmbeddingStore) ChunkExistsByHash(collectionID uint, contentHash string) (bool, error) { + var count int64 + err := s.db.Model(&Chunk{}). + Where("collection_id = ? AND content_hash = ?", collectionID, contentHash). + Count(&count).Error + if err != nil { + return false, fmt.Errorf("failed to check chunk hash: %w", err) + } + return count > 0, nil +} + +// InsertChunks inserts multiple chunks in a transaction. +func (s *EmbeddingStore) InsertChunks(chunks []Chunk) error { + if len(chunks) == 0 { + return nil + } + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&chunks).Error; err != nil { + return fmt.Errorf("failed to insert chunks: %w", err) + } + return nil + }) +} + +// DeleteChunksByFile removes all chunks for a specific file in a collection. +func (s *EmbeddingStore) DeleteChunksByFile(collectionID uint, filePath string) error { + err := s.db.Where("collection_id = ? AND file_path = ?", collectionID, filePath). + Delete(&Chunk{}).Error + if err != nil { + return fmt.Errorf("failed to delete chunks for file %s: %w", filePath, err) + } + return nil +} + +// DeleteChunksByCollection removes all chunks for a collection. +func (s *EmbeddingStore) DeleteChunksByCollection(collectionID uint) error { + err := s.db.Where("collection_id = ?", collectionID).Delete(&Chunk{}).Error + if err != nil { + return fmt.Errorf("failed to delete chunks for collection %d: %w", collectionID, err) + } + return nil +} + +// EncodeEmbedding converts a float32 slice to a little-endian byte slice. +func EncodeEmbedding(vec []float32) []byte { + buf := make([]byte, len(vec)*4) + for i, v := range vec { + binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(v)) + } + return buf +} + +// DecodeEmbedding converts a little-endian byte slice to a float32 slice. +func DecodeEmbedding(data []byte) []float32 { + n := len(data) / 4 + vec := make([]float32, n) + for i := 0; i < n; i++ { + vec[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[i*4:])) + } + return vec +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/embeddings_test.go b/go/plugins/gitrepo-mcp/internal/storage/embeddings_test.go new file mode 100644 index 000000000..e43fdd7da --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/embeddings_test.go @@ -0,0 +1,173 @@ +package storage + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeDecodeEmbedding(t *testing.T) { + original := []float32{1.0, -2.5, 3.14159, 0.0, math.MaxFloat32, math.SmallestNonzeroFloat32} + encoded := EncodeEmbedding(original) + decoded := DecodeEmbedding(encoded) + + require.Len(t, decoded, len(original)) + for i := range original { + assert.Equal(t, original[i], decoded[i], "mismatch at index %d", i) + } +} + +func TestEncodeEmbedding_Empty(t *testing.T) { + encoded := EncodeEmbedding(nil) + assert.Len(t, encoded, 0) + decoded := DecodeEmbedding(encoded) + assert.Len(t, decoded, 0) +} + +func TestEmbeddingStore_GetOrCreateCollection(t *testing.T) { + mgr := newTestManager(t) + repoStore := NewRepoStore(mgr.DB()) + embStore := NewEmbeddingStore(mgr.DB()) + + // Must create repo first (foreign key) + require.NoError(t, repoStore.Create(&Repo{ + Name: "test-repo", + URL: "https://example.com/test.git", + Branch: "main", + Status: RepoStatusCloned, + LocalPath: "/data/repos/test-repo", + })) + + // Create collection + coll, err := embStore.GetOrCreateCollection("test-repo", "gemma-300m", 768) + require.NoError(t, err) + assert.Equal(t, "test-repo", coll.RepoName) + assert.Equal(t, "gemma-300m", coll.Model) + assert.Equal(t, 768, coll.Dimensions) + assert.NotZero(t, coll.ID) + + // Get same collection (idempotent) + coll2, err := embStore.GetOrCreateCollection("test-repo", "gemma-300m", 768) + require.NoError(t, err) + assert.Equal(t, coll.ID, coll2.ID) +} + +func TestEmbeddingStore_InsertAndQueryChunks(t *testing.T) { + mgr := newTestManager(t) + repoStore := NewRepoStore(mgr.DB()) + embStore := NewEmbeddingStore(mgr.DB()) + + require.NoError(t, repoStore.Create(&Repo{ + Name: "test-repo", + URL: "https://example.com/test.git", + Branch: "main", + Status: RepoStatusCloned, + LocalPath: "/data/repos/test-repo", + })) + + coll, err := embStore.GetOrCreateCollection("test-repo", "gemma-300m", 768) + require.NoError(t, err) + + embedding := EncodeEmbedding([]float32{1.0, 2.0, 3.0}) + chunks := []Chunk{ + { + CollectionID: coll.ID, + FilePath: "main.go", + LineStart: 1, + LineEnd: 10, + ChunkType: "function", + Content: "func main() {}", + ContentHash: "abc123", + Embedding: embedding, + }, + { + CollectionID: coll.ID, + FilePath: "main.go", + LineStart: 12, + LineEnd: 20, + ChunkType: "function", + Content: "func helper() {}", + ContentHash: "def456", + Embedding: embedding, + }, + } + + require.NoError(t, embStore.InsertChunks(chunks)) + + // Query chunks + got, err := embStore.GetChunksByCollection(coll.ID) + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Equal(t, "main.go", got[0].FilePath) +} + +func TestEmbeddingStore_ChunkExistsByHash(t *testing.T) { + mgr := newTestManager(t) + repoStore := NewRepoStore(mgr.DB()) + embStore := NewEmbeddingStore(mgr.DB()) + + require.NoError(t, repoStore.Create(&Repo{ + Name: "test-repo", + URL: "https://example.com/test.git", + Branch: "main", + Status: RepoStatusCloned, + LocalPath: "/data/repos/test-repo", + })) + + coll, err := embStore.GetOrCreateCollection("test-repo", "gemma-300m", 768) + require.NoError(t, err) + + embedding := EncodeEmbedding([]float32{1.0}) + require.NoError(t, embStore.InsertChunks([]Chunk{ + { + CollectionID: coll.ID, + FilePath: "main.go", + LineStart: 1, + LineEnd: 10, + ChunkType: "function", + Content: "func main() {}", + ContentHash: "hash1", + Embedding: embedding, + }, + })) + + exists, err := embStore.ChunkExistsByHash(coll.ID, "hash1") + require.NoError(t, err) + assert.True(t, exists) + + exists, err = embStore.ChunkExistsByHash(coll.ID, "hash2") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestEmbeddingStore_DeleteChunksByFile(t *testing.T) { + mgr := newTestManager(t) + repoStore := NewRepoStore(mgr.DB()) + embStore := NewEmbeddingStore(mgr.DB()) + + require.NoError(t, repoStore.Create(&Repo{ + Name: "test-repo", + URL: "https://example.com/test.git", + Branch: "main", + Status: RepoStatusCloned, + LocalPath: "/data/repos/test-repo", + })) + + coll, err := embStore.GetOrCreateCollection("test-repo", "gemma-300m", 768) + require.NoError(t, err) + + embedding := EncodeEmbedding([]float32{1.0}) + require.NoError(t, embStore.InsertChunks([]Chunk{ + {CollectionID: coll.ID, FilePath: "a.go", LineStart: 1, LineEnd: 5, ChunkType: "function", Content: "a", ContentHash: "h1", Embedding: embedding}, + {CollectionID: coll.ID, FilePath: "b.go", LineStart: 1, LineEnd: 5, ChunkType: "function", Content: "b", ContentHash: "h2", Embedding: embedding}, + })) + + require.NoError(t, embStore.DeleteChunksByFile(coll.ID, "a.go")) + + chunks, err := embStore.GetChunksByCollection(coll.ID) + require.NoError(t, err) + assert.Len(t, chunks, 1) + assert.Equal(t, "b.go", chunks[0].FilePath) +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/models.go b/go/plugins/gitrepo-mcp/internal/storage/models.go new file mode 100644 index 000000000..cdd5a7805 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/models.go @@ -0,0 +1,58 @@ +package storage + +import ( + "time" +) + +// RepoStatus represents the state of a git repository. +type RepoStatus string + +const ( + RepoStatusCloning RepoStatus = "cloning" + RepoStatusCloned RepoStatus = "cloned" + RepoStatusIndexing RepoStatus = "indexing" + RepoStatusIndexed RepoStatus = "indexed" + RepoStatusError RepoStatus = "error" +) + +// Repo is the GORM model for a git repository. +type Repo struct { + Name string `gorm:"primaryKey;type:text" json:"name"` + URL string `gorm:"not null;type:text" json:"url"` + Branch string `gorm:"not null;default:'main';type:text" json:"branch"` + Status RepoStatus `gorm:"not null;default:'cloning';type:text" json:"status"` + LocalPath string `gorm:"not null;type:text" json:"localPath"` + LastSynced *time.Time `json:"lastSynced,omitempty"` + LastIndexed *time.Time `json:"lastIndexed,omitempty"` + FileCount int `gorm:"default:0" json:"fileCount"` + ChunkCount int `gorm:"default:0" json:"chunkCount"` + Error *string `gorm:"type:text" json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Collection represents an embedding collection for a repo. +type Collection struct { + ID uint `gorm:"primaryKey;autoIncrement"` + RepoName string `gorm:"not null;uniqueIndex;type:text"` + Repo Repo `gorm:"foreignKey:RepoName;references:Name;constraint:OnDelete:CASCADE"` + Model string `gorm:"not null;type:text"` + Dimensions int `gorm:"not null"` +} + +// Chunk represents a code chunk with its embedding. +type Chunk struct { + ID uint `gorm:"primaryKey;autoIncrement"` + CollectionID uint `gorm:"not null;index:idx_chunks_collection"` + Collection Collection `gorm:"foreignKey:CollectionID;constraint:OnDelete:CASCADE"` + FilePath string `gorm:"not null;type:text;index:idx_chunks_file"` + LineStart int `gorm:"not null"` + LineEnd int `gorm:"not null"` + ChunkType string `gorm:"not null;type:text"` // "function", "method", "class", "heading", "document" + ChunkName *string `gorm:"type:text"` + Content string `gorm:"not null;type:text"` + ContentHash string `gorm:"not null;type:text;index:idx_chunks_hash"` + Embedding []byte `gorm:"not null;type:blob"` + Metadata *string `gorm:"type:text"` // JSON + CreatedAt time.Time +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/repos.go b/go/plugins/gitrepo-mcp/internal/storage/repos.go new file mode 100644 index 000000000..098e863de --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/repos.go @@ -0,0 +1,59 @@ +package storage + +import ( + "fmt" + + "gorm.io/gorm" +) + +// RepoStore provides CRUD operations for repos. +type RepoStore struct { + db *gorm.DB +} + +// NewRepoStore creates a new RepoStore. +func NewRepoStore(db *gorm.DB) *RepoStore { + return &RepoStore{db: db} +} + +// Create inserts a new repo. +func (s *RepoStore) Create(repo *Repo) error { + if err := s.db.Create(repo).Error; err != nil { + return fmt.Errorf("failed to create repo %s: %w", repo.Name, err) + } + return nil +} + +// Get retrieves a repo by name. +func (s *RepoStore) Get(name string) (*Repo, error) { + var repo Repo + if err := s.db.Where("name = ?", name).First(&repo).Error; err != nil { + return nil, fmt.Errorf("failed to get repo %s: %w", name, err) + } + return &repo, nil +} + +// List retrieves all repos. +func (s *RepoStore) List() ([]Repo, error) { + var repos []Repo + if err := s.db.Order("name ASC").Find(&repos).Error; err != nil { + return nil, fmt.Errorf("failed to list repos: %w", err) + } + return repos, nil +} + +// Update saves changes to an existing repo. +func (s *RepoStore) Update(repo *Repo) error { + if err := s.db.Save(repo).Error; err != nil { + return fmt.Errorf("failed to update repo %s: %w", repo.Name, err) + } + return nil +} + +// Delete removes a repo by name. CASCADE deletes collections and chunks. +func (s *RepoStore) Delete(name string) error { + if err := s.db.Where("name = ?", name).Delete(&Repo{}).Error; err != nil { + return fmt.Errorf("failed to delete repo %s: %w", name, err) + } + return nil +} diff --git a/go/plugins/gitrepo-mcp/internal/storage/repos_test.go b/go/plugins/gitrepo-mcp/internal/storage/repos_test.go new file mode 100644 index 000000000..1d6b76d1c --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/storage/repos_test.go @@ -0,0 +1,109 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoStore_CRUD(t *testing.T) { + mgr := newTestManager(t) + store := NewRepoStore(mgr.DB()) + + // Create + repo := &Repo{ + Name: "test-repo", + URL: "https://github.com/example/test.git", + Branch: "main", + Status: RepoStatusCloning, + LocalPath: "/data/repos/test-repo", + } + require.NoError(t, store.Create(repo)) + + // Get + got, err := store.Get("test-repo") + require.NoError(t, err) + assert.Equal(t, "test-repo", got.Name) + assert.Equal(t, "https://github.com/example/test.git", got.URL) + assert.Equal(t, "main", got.Branch) + assert.Equal(t, RepoStatusCloning, got.Status) + assert.Equal(t, "/data/repos/test-repo", got.LocalPath) + + // List + repos, err := store.List() + require.NoError(t, err) + assert.Len(t, repos, 1) + + // Update + got.Status = RepoStatusCloned + got.FileCount = 42 + require.NoError(t, store.Update(got)) + + updated, err := store.Get("test-repo") + require.NoError(t, err) + assert.Equal(t, RepoStatusCloned, updated.Status) + assert.Equal(t, 42, updated.FileCount) + + // Delete + require.NoError(t, store.Delete("test-repo")) + repos, err = store.List() + require.NoError(t, err) + assert.Len(t, repos, 0) +} + +func TestRepoStore_Get_NotFound(t *testing.T) { + mgr := newTestManager(t) + store := NewRepoStore(mgr.DB()) + + _, err := store.Get("nonexistent") + assert.Error(t, err) +} + +func TestRepoStore_Create_Duplicate(t *testing.T) { + mgr := newTestManager(t) + store := NewRepoStore(mgr.DB()) + + repo := &Repo{ + Name: "dup", + URL: "https://github.com/example/dup.git", + Branch: "main", + Status: RepoStatusCloning, + LocalPath: "/data/repos/dup", + } + require.NoError(t, store.Create(repo)) + + err := store.Create(repo) + assert.Error(t, err) +} + +func TestRepoStore_ListEmpty(t *testing.T) { + mgr := newTestManager(t) + store := NewRepoStore(mgr.DB()) + + repos, err := store.List() + require.NoError(t, err) + assert.Len(t, repos, 0) +} + +func TestRepoStore_ListOrdered(t *testing.T) { + mgr := newTestManager(t) + store := NewRepoStore(mgr.DB()) + + for _, name := range []string{"charlie", "alpha", "bravo"} { + require.NoError(t, store.Create(&Repo{ + Name: name, + URL: "https://example.com/" + name + ".git", + Branch: "main", + Status: RepoStatusCloned, + LocalPath: "/data/repos/" + name, + })) + } + + repos, err := store.List() + require.NoError(t, err) + require.Len(t, repos, 3) + assert.Equal(t, "alpha", repos[0].Name) + assert.Equal(t, "bravo", repos[1].Name) + assert.Equal(t, "charlie", repos[2].Name) +} diff --git a/go/plugins/gitrepo-mcp/internal/ui/embed.go b/go/plugins/gitrepo-mcp/internal/ui/embed.go new file mode 100644 index 000000000..8a41bef90 --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/ui/embed.go @@ -0,0 +1,17 @@ +package ui + +import ( + _ "embed" + "net/http" +) + +//go:embed index.html +var indexHTML []byte + +// Handler returns an http.Handler that serves the embedded SPA. +func Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) //nolint:errcheck + }) +} diff --git a/go/plugins/gitrepo-mcp/internal/ui/embed_test.go b/go/plugins/gitrepo-mcp/internal/ui/embed_test.go new file mode 100644 index 000000000..fd2b2bd2f --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/ui/embed_test.go @@ -0,0 +1,34 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestIndexHTMLEmbedded(t *testing.T) { + if len(indexHTML) == 0 { + t.Fatal("indexHTML is empty") + } + if !strings.Contains(string(indexHTML), "") { + t.Fatal("indexHTML does not look like HTML") + } +} + +func TestHandlerServesHTML(t *testing.T) { + h := Handler() + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("expected text/html content-type, got %s", ct) + } + if !strings.Contains(w.Body.String(), "Git Repos") { + t.Error("response does not contain expected title") + } +} diff --git a/go/plugins/gitrepo-mcp/internal/ui/index.html b/go/plugins/gitrepo-mcp/internal/ui/index.html new file mode 100644 index 000000000..c3da7339d --- /dev/null +++ b/go/plugins/gitrepo-mcp/internal/ui/index.html @@ -0,0 +1,998 @@ + + + + + +Git Repos + + + +
+ +

Git Repos

+
+ + +
+
+ +
+
+
+ + Repositories + 0 +
+
+
+ + +
+ + + + + + + diff --git a/go/plugins/gitrepo-mcp/main.go b/go/plugins/gitrepo-mcp/main.go new file mode 100644 index 000000000..05b86fb3d --- /dev/null +++ b/go/plugins/gitrepo-mcp/main.go @@ -0,0 +1,473 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/embedder" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/indexer" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/repo" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/search" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/server" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/storage" + "github.com/kagent-dev/kagent/go/plugins/gitrepo-mcp/internal/ui" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +var ( + cfgDataDir string + cfgDBPath string +) + +func main() { + rootCmd := &cobra.Command{ + Use: "gitrepo-mcp", + Short: "Git repository semantic search and structural search MCP server", + Long: "A standalone MCP server that clones git repos, indexes them with local CPU embeddings, and exposes semantic search + ast-grep structural search.", + } + + rootCmd.PersistentFlags().StringVar(&cfgDataDir, "data-dir", envOrDefault("GITREPO_DATA_DIR", "./data"), "data directory for cloned repos and database") + rootCmd.PersistentFlags().StringVar(&cfgDBPath, "db-path", envOrDefault("GITREPO_DB_PATH", ""), "SQLite database file path (default: /gitrepo.db)") + + rootCmd.AddCommand( + newServeCmd(), + newAddCmd(), + newListCmd(), + newRemoveCmd(), + newSyncCmd(), + newSyncAllCmd(), + newIndexCmd(), + newSearchCmd(), + newAstSearchCmd(), + ) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getDBPath() string { + if cfgDBPath != "" { + return cfgDBPath + } + return cfgDataDir + "/gitrepo.db" +} + +func initStorage() (*storage.Manager, error) { + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: getDBPath(), + DataDir: cfgDataDir, + } + mgr, err := storage.NewManager(cfg) + if err != nil { + return nil, err + } + if err := mgr.Initialize(); err != nil { + return nil, err + } + return mgr, nil +} + +func initRepoManager() (*repo.Manager, error) { + dbMgr, err := initStorage() + if err != nil { + return nil, err + } + repoStore := storage.NewRepoStore(dbMgr.DB()) + reposDir := filepath.Join(cfgDataDir, "repos") + return repo.NewManager(repoStore, reposDir), nil +} + +func newServeCmd() *cobra.Command { + var addr, transport string + + cmd := &cobra.Command{ + Use: "serve", + Short: "Start the REST API and MCP server", + RunE: func(cmd *cobra.Command, args []string) error { + mgr, err := initStorage() + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + repoStore := storage.NewRepoStore(mgr.DB()) + embStore := storage.NewEmbeddingStore(mgr.DB()) + emb := embedder.NewHashEmbedder(768) + + reposDir := filepath.Join(cfgDataDir, "repos") + repoMgr := repo.NewManager(repoStore, reposDir) + idx := indexer.NewIndexer(repoStore, embStore, emb) + s := search.NewSearcher(repoStore, embStore, emb) + astS := search.NewAstSearcher(repoStore) + + mcpSrv := server.NewMCPServer(repoStore, repoMgr, idx, s, astS, reposDir) + + if transport == "stdio" { + return serveStdio(cmd.Context(), mcpSrv) + } + + return serveHTTP(addr, repoStore, repoMgr, idx, s, astS, reposDir, mcpSrv) + }, + } + + cmd.Flags().StringVar(&addr, "addr", envOrDefault("GITREPO_ADDR", ":8090"), "listen address") + cmd.Flags().StringVar(&transport, "transport", envOrDefault("GITREPO_TRANSPORT", "http"), "transport mode: http or stdio") + + return cmd +} + +func serveHTTP(addr string, repoStore *storage.RepoStore, repoMgr *repo.Manager, idx *indexer.Indexer, s *search.Searcher, astS *search.AstSearcher, reposDir string, mcpSrv *server.MCPServer) error { + restSrv := server.NewServer(repoStore, repoMgr, idx, s, astS, reposDir) + + mux := http.NewServeMux() + mux.Handle("/mcp/", http.StripPrefix("/mcp", mcpSrv)) + mux.Handle("/ui/", ui.Handler()) + mux.Handle("/", restSrv.Handler()) + + httpSrv := &http.Server{ + Addr: addr, + Handler: mux, + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + go func() { + <-ctx.Done() + log.Printf("shutting down server...") + _ = httpSrv.Close() + }() + + log.Printf("gitrepo-mcp serve: addr=%s transport=http data-dir=%s", addr, cfgDataDir) + log.Printf(" REST API: http://localhost%s/api/", addr) + log.Printf(" MCP: http://localhost%s/mcp/", addr) + if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +func serveStdio(ctx context.Context, mcpSrv *server.MCPServer) error { + log.Printf("gitrepo-mcp serve: transport=stdio data-dir=%s", cfgDataDir) + + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer cancel() + + return mcpSrv.Server().Run(ctx, &mcpsdk.StdioTransport{}) +} + +func newAddCmd() *cobra.Command { + var url, branch string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Register and clone a git repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr, err := initRepoManager() + if err != nil { + return fmt.Errorf("failed to initialize: %w", err) + } + + name := args[0] + r, err := mgr.Add(name, url, branch) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) + }, + } + + cmd.Flags().StringVar(&url, "url", "", "git repository URL") + cmd.Flags().StringVar(&branch, "branch", "main", "git branch") + _ = cmd.MarkFlagRequired("url") + + return cmd +} + +func newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List registered repositories", + RunE: func(cmd *cobra.Command, args []string) error { + mgr, err := initRepoManager() + if err != nil { + return fmt.Errorf("failed to initialize: %w", err) + } + + repos, err := mgr.List() + if err != nil { + return fmt.Errorf("failed to list repos: %w", err) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(repos) + }, + } +} + +func newRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a repository and its embeddings", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr, err := initRepoManager() + if err != nil { + return fmt.Errorf("failed to initialize: %w", err) + } + + name := args[0] + if err := mgr.Remove(name); err != nil { + return err + } + + log.Printf("removed repo %s", name) + return nil + }, + } +} + +func newSyncCmd() *cobra.Command { + var reindex bool + + cmd := &cobra.Command{ + Use: "sync ", + Short: "Pull latest changes for a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + if reindex { + dbMgr, err := initStorage() + if err != nil { + return fmt.Errorf("failed to initialize: %w", err) + } + repoStore := storage.NewRepoStore(dbMgr.DB()) + embStore := storage.NewEmbeddingStore(dbMgr.DB()) + emb := embedder.NewHashEmbedder(768) + reposDir := filepath.Join(cfgDataDir, "repos") + mgr := repo.NewManager(repoStore, reposDir) + idx := indexer.NewIndexer(repoStore, embStore, emb) + + r, reindexed, err := mgr.SyncAndReindex(name, func(n string) error { + log.Printf("re-indexing repo %s ...", n) + return idx.Index(n) + }) + if err != nil { + return err + } + if reindexed { + log.Printf("repo %s synced and re-indexed", name) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) + } + + mgr, err := initRepoManager() + if err != nil { + return fmt.Errorf("failed to initialize: %w", err) + } + + r, err := mgr.Sync(name) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) + }, + } + + cmd.Flags().BoolVar(&reindex, "reindex", false, "re-index the repo if it was previously indexed") + + return cmd +} + +func newSyncAllCmd() *cobra.Command { + var reindex bool + + cmd := &cobra.Command{ + Use: "sync-all", + Short: "Sync all repositories with optional re-indexing", + RunE: func(cmd *cobra.Command, args []string) error { + dbMgr, err := initStorage() + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + repoStore := storage.NewRepoStore(dbMgr.DB()) + reposDir := filepath.Join(cfgDataDir, "repos") + mgr := repo.NewManager(repoStore, reposDir) + + var reindexFn func(string) error + if reindex { + embStore := storage.NewEmbeddingStore(dbMgr.DB()) + emb := embedder.NewHashEmbedder(768) + idx := indexer.NewIndexer(repoStore, embStore, emb) + reindexFn = func(name string) error { + log.Printf("re-indexing repo %s ...", name) + return idx.Index(name) + } + } + + results, err := mgr.SyncAll(reindexFn) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + }, + } + + cmd.Flags().BoolVar(&reindex, "reindex", true, "re-index repos that were previously indexed") + + return cmd +} + +func newIndexCmd() *cobra.Command { + var batchSize int + + cmd := &cobra.Command{ + Use: "index ", + Short: "Index a repository for semantic search", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dbMgr, err := initStorage() + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + repoStore := storage.NewRepoStore(dbMgr.DB()) + embStore := storage.NewEmbeddingStore(dbMgr.DB()) + emb := embedder.NewHashEmbedder(768) + + idx := indexer.NewIndexer(repoStore, embStore, emb) + if batchSize > 0 { + idx.SetBatchSize(batchSize) + } + + name := args[0] + log.Printf("indexing repo %s ...", name) + if err := idx.Index(name); err != nil { + return err + } + + r, err := repoStore.Get(name) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) + }, + } + + cmd.Flags().IntVar(&batchSize, "batch-size", 32, "embedding batch size") + + return cmd +} + +func newSearchCmd() *cobra.Command { + var query string + var limit int + var contextLines int + + cmd := &cobra.Command{ + Use: "search ", + Short: "Semantic search within a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dbMgr, err := initStorage() + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + repoStore := storage.NewRepoStore(dbMgr.DB()) + embStore := storage.NewEmbeddingStore(dbMgr.DB()) + emb := embedder.NewHashEmbedder(768) + + s := search.NewSearcher(repoStore, embStore, emb) + + name := args[0] + results, err := s.Search(query, name, limit, contextLines) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + }, + } + + cmd.Flags().StringVarP(&query, "query", "c", "", "search query") + cmd.Flags().IntVar(&limit, "limit", 10, "maximum number of results") + cmd.Flags().IntVar(&contextLines, "context", 0, "number of context lines before and after each result") + _ = cmd.MarkFlagRequired("query") + + return cmd +} + +func newAstSearchCmd() *cobra.Command { + var pattern, lang string + + cmd := &cobra.Command{ + Use: "ast-search ", + Short: "Structural code search using ast-grep", + Long: "Search for code patterns using ast-grep structural matching (e.g., 'func $NAME($$$) error').", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dbMgr, err := initStorage() + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + repoStore := storage.NewRepoStore(dbMgr.DB()) + s := search.NewAstSearcher(repoStore) + + name := args[0] + results, err := s.Search(pattern, name, lang) + if err != nil { + return err + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + }, + } + + cmd.Flags().StringVar(&pattern, "pattern", "", "ast-grep pattern (e.g., 'func $NAME($$$) error')") + cmd.Flags().StringVar(&lang, "lang", "", "language filter (e.g., go, python, javascript)") + _ = cmd.MarkFlagRequired("pattern") + + return cmd +} diff --git a/go/plugins/kagent-plugin-bridge.js b/go/plugins/kagent-plugin-bridge.js new file mode 100644 index 000000000..4ecc65976 --- /dev/null +++ b/go/plugins/kagent-plugin-bridge.js @@ -0,0 +1,67 @@ +// kagent-plugin-bridge.js — lightweight bridge for plugin UIs +// Include this script in your plugin's HTML to communicate with the kagent host. +// +// Usage: +// +// +// +// Protocol: all messages use { type: "kagent:", payload: {...} } +// Host -> Plugin: kagent:context (theme, namespace, authToken) +// Plugin -> Host: kagent:ready, kagent:navigate, kagent:resize, kagent:badge, kagent:title + +const kagent = { + _ready: false, + _listeners: {}, + + // Call on plugin load to establish connection with kagent host + connect() { + window.addEventListener("message", (event) => { + if (event.data?.type === "kagent:context") { + const { theme, namespace, authToken } = event.data.payload; + this._emit("context", { theme, namespace, authToken }); + } + }); + window.parent.postMessage({ type: "kagent:ready", payload: {} }, "*"); + this._ready = true; + }, + + // Listen for context updates (theme, namespace, auth changes) + onContext(fn) { + this._on("context", fn); + }, + + // Request host navigation to a different page + navigate(href) { + window.parent.postMessage({ type: "kagent:navigate", payload: { href } }, "*"); + }, + + // Update sidebar badge for this plugin + setBadge(count, label) { + window.parent.postMessage({ type: "kagent:badge", payload: { count, label } }, "*"); + }, + + // Set page title shown above the iframe + setTitle(title) { + window.parent.postMessage({ type: "kagent:title", payload: { title } }, "*"); + }, + + // Report content height for auto-resize (defaults to document.body.scrollHeight) + reportHeight(height) { + window.parent.postMessage( + { type: "kagent:resize", payload: { height: height ?? document.body.scrollHeight } }, + "*" + ); + }, + + _on(event, fn) { + (this._listeners[event] ??= []).push(fn); + }, + _emit(event, data) { + (this._listeners[event] ?? []).forEach((fn) => fn(data)); + }, +}; diff --git a/go/plugins/kanban-mcp/CLAUDE.md b/go/plugins/kanban-mcp/CLAUDE.md new file mode 100644 index 000000000..32b15d28c --- /dev/null +++ b/go/plugins/kanban-mcp/CLAUDE.md @@ -0,0 +1,211 @@ +# CLAUDE.md — kanban-mcp + +Guide for AI agents working in the `go/cmd/kanban-mcp/` subtree. + +## What This Is + +A self-contained Go binary that provides a Kanban task board via three interfaces: +- **MCP** (Model Context Protocol) — 12 tools for AI agent integration (10 task + 2 attachment) +- **REST API** — CRUD endpoints for tasks, attachments, and board +- **Embedded SPA** — single HTML file served at `/`, with live SSE updates + +## Project Layout + +``` +go/cmd/kanban-mcp/ +├── main.go # Entry point, wires config → DB → service → server +├── server.go # HTTP mux: /mcp, /events, /api/*, / +├── Dockerfile +├── internal/ +│ ├── config/config.go # CLI flags + KANBAN_* env fallback +│ ├── db/ +│ │ ├── models.go # GORM Task + Attachment models + TaskStatus enum +│ │ └── manager.go # DB init (SQLite or Postgres) +│ ├── service/task_service.go # Business logic + Broadcaster interface +│ ├── mcp/tools.go # 12 MCP tool handlers (10 task + 2 attachment) +│ ├── api/handlers.go # REST handlers (tasks, attachments, board) +│ ├── sse/hub.go # SSE fan-out hub (implements Broadcaster) +│ └── ui/ +│ ├── embed.go # //go:embed index.html +│ ├── embed_test.go +│ └── index.html # Full SPA — CSS + JS, no build step +``` + +## Critical: API JSON Field Naming + +The `db.Task` GORM model uses Go PascalCase struct fields **without explicit JSON tags**, so the REST API and SSE events return **PascalCase** field names: + +```json +{ + "ID": 1, + "Title": "Fix bug", + "Description": "Details here", + "Status": "Develop", + "Assignee": "alice", + "Labels": ["priority:high", "team:platform"], + "UserInputNeeded": false, + "ParentID": null, + "Subtasks": [{ "ID": 2, "Title": "Sub", ... }], + "Attachments": [{ "ID": 1, "TaskID": 1, "Type": "file", "Filename": "DESIGN.md", ... }], + "CreatedAt": "2026-02-25T17:32:38Z", + "UpdatedAt": "2026-02-25T17:32:38Z" +} +``` + +The REST API **accepts** snake_case for write operations (via explicit `json:"..."` tags on handler input structs): +- POST/PUT body: `title`, `description`, `status`, `assignee`, `labels`, `user_input_needed` + +**The UI `index.html` must normalize both casings.** A `norm()` function maps PascalCase → camelCase for rendering. If this breaks, cards show "(untitled)" and "#undefined". + +## Board API Response Shape + +`GET /api/board` and SSE `board_update` events both return: + +```json +{ + "columns": [ + { + "status": "Inbox", + "tasks": [{ "ID": 1, "Title": "...", ... }] + }, + { + "status": "Design", + "tasks": [] + } + ] +} +``` + +- `columns[].status` is lowercase `json:"status"` (from `api.Column` / `mcp.Column` structs) +- `columns[].tasks[]` fields are PascalCase (from `db.Task` with no JSON tags) + +## SSE Event Structure + +SSE events at `/events` are wrapped in an `Event` envelope: + +```json +{ + "type": "board_update", + "data": { "columns": [...] } +} +``` + +- On connect: `event: snapshot\ndata: \n\n` +- On mutations: `data: \n\n` + +The UI must unwrap `ev.data.columns` from the parsed SSE payload. + +## Workflow Statuses (Enum) + +Exactly 8 statuses, in order: + +``` +Inbox → Design → Develop → Testing → SecurityScan → CodeReview → Documentation → Done +``` + +These are Go constants in `db/models.go` (`StatusInbox` through `StatusDone`). The UI mirrors this in the `WORKFLOW` array and provides human-readable labels via `COL_LABELS` (e.g., `SecurityScan` → "Security Scan"). + +## 12 MCP Tools + +| Tool | Input | Description | +|------|-------|-------------| +| `list_tasks` | `status?`, `assignee?`, `label?` | List top-level tasks with optional filter | +| `get_task` | `id` | Get task by ID with subtasks + attachments populated | +| `create_task` | `title`, `description?`, `status?`, `labels?` | Create top-level task (defaults to Inbox) | +| `create_subtask` | `parent_id`, `title`, `description?`, `status?`, `labels?` | One level deep only | +| `assign_task` | `id`, `assignee` | Empty string clears assignment | +| `move_task` | `id`, `status` | Validates against enum | +| `update_task` | `id`, `title?`, `description?`, `status?`, `assignee?`, `labels?`, `user_input_needed?` | Partial update | +| `set_user_input_needed` | `id`, `needed` | Human-in-the-loop flag | +| `delete_task` | `id` | Cascades to subtasks + attachments | +| `get_board` | (none) | Full board grouped by columns with attachments | +| `add_attachment` | `task_id`, `type` (`file`\|`link`), `filename?`, `content?`, `url?`, `title?` | Add attachment to top-level task | +| `delete_attachment` | `id` | Delete an attachment by ID | + +## REST API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/board` | Full board view | +| GET | `/api/tasks` | List tasks (`?status=`, `?assignee=`) | +| POST | `/api/tasks` | Create task (`{title, description?, status?}`) | +| GET | `/api/tasks/:id` | Get single task | +| PUT | `/api/tasks/:id` | Partial update (`{title?, description?, status?, assignee?, user_input_needed?}`) | +| DELETE | `/api/tasks/:id` | Delete task + subtasks + attachments | +| GET | `/api/tasks/:id/subtasks` | List subtasks | +| POST | `/api/tasks/:id/subtasks` | Create subtask | +| POST | `/api/tasks/:id/attachments` | Add attachment (top-level tasks only) | +| DELETE | `/api/attachments/:id` | Delete attachment by ID | +| GET | `/events` | SSE stream | +| * | `/mcp` | MCP Streamable HTTP endpoint | +| GET | `/` | Embedded SPA | + +## Build & Run + +```bash +# Build +cd go && go build -o kanban-mcp ./cmd/kanban-mcp/ + +# Run (HTTP mode, SQLite default) +./kanban-mcp +# → listening on :8080 + +# Run (stdio mode for MCP client piping) +./kanban-mcp --transport=stdio + +# Run (Postgres) +./kanban-mcp --db-type=postgres --db-url="postgres://user:pass@host/db" +``` + +All flags have `KANBAN_*` environment variable fallbacks: +- `KANBAN_ADDR` (default `:8080`) +- `KANBAN_TRANSPORT` (default `http`) +- `KANBAN_DB_TYPE` (default `sqlite`) +- `KANBAN_DB_PATH` (default `./kanban.db`) +- `KANBAN_DB_URL` +- `KANBAN_LOG_LEVEL` (default `info`) + +## UI Development + +The UI is a **single embedded HTML file** at `internal/ui/index.html`. No npm, no build step. Changes require rebuilding the Go binary since the file is embedded via `//go:embed`. + +Key UI architecture: +- Pure vanilla JS (no framework) +- CSS variables for theming +- SSE for live updates (reconnects automatically) +- `norm()` function normalizes PascalCase API fields to camelCase for rendering +- Column color coding via CSS classes (`.col-inbox`, `.col-design`, etc.) +- Cards show: title, description preview, ID badge, assignee badge, HITL flag, subtask count, label chips, attachment icon + count +- Click card opens detail modal with full task info + attachments (markdown rendered, diffs as code, links clickable) +- Navigation buttons show the target column name + +## Testing + +```bash +cd go + +# Unit tests +go test ./cmd/kanban-mcp/... + +# Specific package +go test ./cmd/kanban-mcp/internal/api/... +go test ./cmd/kanban-mcp/internal/mcp/... +go test ./cmd/kanban-mcp/internal/sse/... +go test ./cmd/kanban-mcp/internal/service/... +go test ./cmd/kanban-mcp/internal/config/... + +# Postgres integration test (requires running Postgres) +KANBAN_TEST_POSTGRES_URL="postgres://..." go test ./cmd/kanban-mcp/internal/service/ -run TestPostgres -v +``` + +## Common Pitfalls + +1. **PascalCase vs camelCase**: The GORM `Task` and `Attachment` models have no `json:"..."` tags, so API responses use Go field names (PascalCase). The UI's `norm()` function handles both. Any new fields added to `db.Task` or `db.Attachment` must also be mapped in `norm()`. + +2. **Port already in use**: The binary exits immediately if `:8080` is occupied. Kill old processes: `kill -9 $(lsof -ti :8080)`. + +3. **Stale UI after code change**: The HTML is `//go:embed`'d — you must `go build` again after editing `index.html`. + +4. **Subtasks are one level deep**: `create_subtask` on a subtask returns an error. The `ParentID` field is `*uint` (nullable pointer). + +5. **SSE snapshot vs stream**: New SSE subscribers get the last broadcast as a `snapshot` event, then receive `data` events for mutations. The UI handles both paths. diff --git a/go/plugins/kanban-mcp/Dockerfile b/go/plugins/kanban-mcp/Dockerfile new file mode 100644 index 000000000..b9f470c19 --- /dev/null +++ b/go/plugins/kanban-mcp/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go/ ./go/ +WORKDIR /app/go +RUN go build -o kanban-mcp ./plugins/kanban-mcp + +FROM alpine:3.20 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /app/go/kanban-mcp /usr/local/bin/kanban-mcp +ENTRYPOINT ["kanban-mcp"] diff --git a/go/plugins/kanban-mcp/go.mod b/go/plugins/kanban-mcp/go.mod new file mode 100644 index 000000000..5d37caa45 --- /dev/null +++ b/go/plugins/kanban-mcp/go.mod @@ -0,0 +1,37 @@ +module github.com/kagent-dev/kagent/go/plugins/kanban-mcp + +go 1.25.7 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/modelcontextprotocol/go-sdk v1.4.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.26.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.20.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go/plugins/kanban-mcp/go.sum b/go/plugins/kanban-mcp/go.sum new file mode 100644 index 000000000..af5681c74 --- /dev/null +++ b/go/plugins/kanban-mcp/go.sum @@ -0,0 +1,80 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw= +gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/go/plugins/kanban-mcp/internal/api/handlers.go b/go/plugins/kanban-mcp/internal/api/handlers.go new file mode 100644 index 000000000..31bb3d5cb --- /dev/null +++ b/go/plugins/kanban-mcp/internal/api/handlers.go @@ -0,0 +1,346 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "gorm.io/gorm" +) + +// Board groups top-level tasks by status column. +type Board struct { + Columns []Column `json:"columns"` +} + +// Column holds tasks for a single status in the workflow. +type Column struct { + Status string `json:"status"` + Tasks []*db.Task `json:"tasks"` +} + +// writeJSON encodes v as JSON with the given HTTP status code. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) //nolint:errcheck +} + +// writeError sends a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// httpStatus maps service/DB errors to HTTP status codes. +func httpStatus(err error) int { + if errors.Is(err, gorm.ErrRecordNotFound) { + return http.StatusNotFound + } + msg := err.Error() + if strings.Contains(msg, "invalid status") || + strings.Contains(msg, "subtasks cannot have subtasks") || + strings.Contains(msg, "attachments can only be added to top-level tasks") || + strings.Contains(msg, "type must be") || + strings.Contains(msg, "filename and content required") || + strings.Contains(msg, "url required for link") { + return http.StatusBadRequest + } + return http.StatusInternalServerError +} + +// parseID extracts the uint task ID and optional suffix from a path like +// /api/tasks/42 or /api/tasks/42/subtasks. +func parseID(path string) (uint, string, bool) { + trimmed := strings.TrimPrefix(path, "/api/tasks/") + parts := strings.SplitN(trimmed, "/", 2) + id, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return 0, "", false + } + suffix := "" + if len(parts) > 1 { + suffix = "/" + parts[1] + } + return uint(id), suffix, true +} + +// parseAttachmentID extracts the attachment ID from /api/attachments/42. +func parseAttachmentID(path string) (uint, bool) { + trimmed := strings.TrimPrefix(path, "/api/attachments/") + id, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return 0, false + } + return uint(id), true +} + +// TasksHandler handles /api/tasks (GET list, POST create). +func TasksHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + filter := service.TaskFilter{} + if s := r.URL.Query().Get("status"); s != "" { + ts := db.TaskStatus(s) + filter.Status = &ts + } + if a := r.URL.Query().Get("assignee"); a != "" { + filter.Assignee = &a + } + if l := r.URL.Query().Get("label"); l != "" { + filter.Label = &l + } + tasks, err := svc.ListTasks(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, tasks) + + case http.MethodPost: + var body struct { + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Labels []string `json:"labels"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + req := service.CreateTaskRequest{ + Title: body.Title, + Description: body.Description, + Status: db.TaskStatus(body.Status), + Labels: body.Labels, + } + task, err := svc.CreateTask(r.Context(), req) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusCreated, task) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + +// TaskHandler handles /api/tasks/{id} (GET, PUT, DELETE), +// /api/tasks/{id}/subtasks (GET, POST), and /api/tasks/{id}/attachments (POST). +func TaskHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, suffix, ok := parseID(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + + if suffix == "/subtasks" { + handleSubtasks(w, r, svc, id) + return + } + + if suffix == "/attachments" { + handleTaskAttachments(w, r, svc, id) + return + } + + if suffix != "" { + http.NotFound(w, r) + return + } + + handleTask(w, r, svc, id) + } +} + +// AttachmentHandler handles /api/attachments/{id} (DELETE). +func AttachmentHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, ok := parseAttachmentID(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := svc.DeleteAttachment(r.Context(), id); err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// handleTask dispatches methods for /api/tasks/{id}. +func handleTask(w http.ResponseWriter, r *http.Request, svc *service.TaskService, id uint) { + switch r.Method { + case http.MethodGet: + task, err := svc.GetTask(r.Context(), id) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, task) + + case http.MethodPut: + var body struct { + Title *string `json:"title"` + Description *string `json:"description"` + Status *string `json:"status"` + Assignee *string `json:"assignee"` + Labels *[]string `json:"labels"` + UserInputNeeded *bool `json:"user_input_needed"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + req := service.UpdateTaskRequest{ + Title: body.Title, + Description: body.Description, + Assignee: body.Assignee, + Labels: body.Labels, + UserInputNeeded: body.UserInputNeeded, + } + if body.Status != nil { + s := db.TaskStatus(*body.Status) + req.Status = &s + } + task, err := svc.UpdateTask(r.Context(), id, req) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, task) + + case http.MethodDelete: + if err := svc.DeleteTask(r.Context(), id); err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleSubtasks dispatches methods for /api/tasks/{id}/subtasks. +func handleSubtasks(w http.ResponseWriter, r *http.Request, svc *service.TaskService, parentID uint) { + switch r.Method { + case http.MethodGet: + pid := parentID + tasks, err := svc.ListTasks(r.Context(), service.TaskFilter{ParentID: &pid}) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, tasks) + + case http.MethodPost: + var body struct { + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Labels []string `json:"labels"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + req := service.CreateTaskRequest{ + Title: body.Title, + Description: body.Description, + Status: db.TaskStatus(body.Status), + Labels: body.Labels, + } + task, err := svc.CreateSubtask(r.Context(), parentID, req) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusCreated, task) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleTaskAttachments dispatches methods for /api/tasks/{id}/attachments. +func handleTaskAttachments(w http.ResponseWriter, r *http.Request, svc *service.TaskService, taskID uint) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body struct { + Type string `json:"type"` + Filename string `json:"filename"` + Content string `json:"content"` + URL string `json:"url"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + req := service.CreateAttachmentRequest{ + Type: db.AttachmentType(body.Type), + Filename: body.Filename, + Content: body.Content, + URL: body.URL, + Title: body.Title, + } + attachment, err := svc.AddAttachment(r.Context(), taskID, req) + if err != nil { + writeError(w, httpStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusCreated, attachment) +} + +// BoardHandler handles GET /api/board. +func BoardHandler(svc *service.TaskService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + tasks, err := svc.ListTasks(r.Context(), service.TaskFilter{}) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + byStatus := make(map[db.TaskStatus][]*db.Task) + for _, t := range tasks { + byStatus[t.Status] = append(byStatus[t.Status], t) + } + + columns := make([]Column, 0, len(db.StatusWorkflow)) + for _, status := range db.StatusWorkflow { + col := Column{ + Status: string(status), + Tasks: byStatus[status], + } + if col.Tasks == nil { + col.Tasks = []*db.Task{} + } + columns = append(columns, col) + } + + writeJSON(w, http.StatusOK, Board{Columns: columns}) + } +} diff --git a/go/plugins/kanban-mcp/internal/api/handlers_test.go b/go/plugins/kanban-mcp/internal/api/handlers_test.go new file mode 100644 index 000000000..9d5d79c95 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/api/handlers_test.go @@ -0,0 +1,311 @@ +package api_test + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + kanbanapi "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/api" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" +) + +// newTestAPI creates a fully-wired test HTTP server backed by an in-memory SQLite DB. +func newTestAPI(t *testing.T) (*httptest.Server, *service.TaskService, *sse.Hub) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.db") + cfg := &config.Config{DBType: config.DBTypeSQLite, DBPath: dbPath} + mgr, err := db.NewManager(cfg) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + + hub := sse.NewHub() + svc := service.NewTaskService(mgr.DB(), hub) + + mux := http.NewServeMux() + mux.HandleFunc("/api/tasks", kanbanapi.TasksHandler(svc)) + mux.HandleFunc("/api/tasks/", kanbanapi.TaskHandler(svc)) + mux.HandleFunc("/api/board", kanbanapi.BoardHandler(svc)) + mux.HandleFunc("/events", hub.ServeSSE) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts, svc, hub +} + +func TestREST_CreateTask(t *testing.T) { + ts, _, _ := newTestAPI(t) + + body := `{"title":"Fix bug","status":"Inbox"}` + resp, err := http.Post(ts.URL+"/api/tasks", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/tasks: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + var task db.Task + if err := json.NewDecoder(resp.Body).Decode(&task); err != nil { + t.Fatalf("decode response: %v", err) + } + if task.Title != "Fix bug" { + t.Errorf("expected title 'Fix bug', got %q", task.Title) + } + if task.ID == 0 { + t.Error("expected non-zero ID") + } +} + +func TestREST_GetTask(t *testing.T) { + ts, svc, _ := newTestAPI(t) + + created, _ := svc.CreateTask(context.Background(), service.CreateTaskRequest{Title: "Test"}) + + resp, err := http.Get(fmt.Sprintf("%s/api/tasks/%d", ts.URL, created.ID)) + if err != nil { + t.Fatalf("GET /api/tasks/%d: %v", created.ID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var task db.Task + json.NewDecoder(resp.Body).Decode(&task) //nolint:errcheck + if task.ID != created.ID { + t.Errorf("expected ID %d, got %d", created.ID, task.ID) + } + + // 404 for missing task + resp404, _ := http.Get(ts.URL + "/api/tasks/99999") + defer resp404.Body.Close() + if resp404.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp404.StatusCode) + } +} + +func TestREST_UpdateTask(t *testing.T) { + ts, svc, _ := newTestAPI(t) + + created, _ := svc.CreateTask(context.Background(), service.CreateTaskRequest{Title: "Orig"}) + + body := `{"status":"Design"}` + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/tasks/%d", ts.URL, created.ID), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT /api/tasks/%d: %v", created.ID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var task db.Task + json.NewDecoder(resp.Body).Decode(&task) //nolint:errcheck + if task.Status != db.StatusDesign { + t.Errorf("expected Design, got %q", task.Status) + } +} + +func TestREST_ListTasks_Filter(t *testing.T) { + ts, svc, _ := newTestAPI(t) + ctx := context.Background() + + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task1", Status: db.StatusInbox}) //nolint:errcheck + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task2", Status: db.StatusDesign}) //nolint:errcheck + + resp, err := http.Get(ts.URL + "/api/tasks?status=Inbox") + if err != nil { + t.Fatalf("GET /api/tasks?status=Inbox: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var tasks []*db.Task + json.NewDecoder(resp.Body).Decode(&tasks) //nolint:errcheck + if len(tasks) != 1 { + t.Errorf("expected 1 Inbox task, got %d", len(tasks)) + } + if len(tasks) > 0 && tasks[0].Title != "Task1" { + t.Errorf("expected Task1, got %q", tasks[0].Title) + } +} + +func TestREST_Subtasks_Create(t *testing.T) { + ts, svc, _ := newTestAPI(t) + + parent, _ := svc.CreateTask(context.Background(), service.CreateTaskRequest{Title: "Parent"}) + + body := `{"title":"Subtask","status":"Inbox"}` + resp, err := http.Post(fmt.Sprintf("%s/api/tasks/%d/subtasks", ts.URL, parent.ID), "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST subtasks: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + var subtask db.Task + json.NewDecoder(resp.Body).Decode(&subtask) //nolint:errcheck + if subtask.ParentID == nil || *subtask.ParentID != parent.ID { + t.Errorf("expected ParentID=%d, got %v", parent.ID, subtask.ParentID) + } +} + +func TestREST_Subtasks_List(t *testing.T) { + ts, svc, _ := newTestAPI(t) + ctx := context.Background() + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub1"}) //nolint:errcheck + svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub2"}) //nolint:errcheck + + resp, err := http.Get(fmt.Sprintf("%s/api/tasks/%d/subtasks", ts.URL, parent.ID)) + if err != nil { + t.Fatalf("GET subtasks: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var tasks []*db.Task + json.NewDecoder(resp.Body).Decode(&tasks) //nolint:errcheck + if len(tasks) != 2 { + t.Errorf("expected 2 subtasks, got %d", len(tasks)) + } +} + +func TestREST_DeleteTask_Cascade(t *testing.T) { + ts, svc, _ := newTestAPI(t) + ctx := context.Background() + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub1"}) //nolint:errcheck + svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub2"}) //nolint:errcheck + + req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/tasks/%d", ts.URL, parent.ID), nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("DELETE /api/tasks/%d: %v", parent.ID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + + // Verify parent is gone + if _, err := svc.GetTask(ctx, parent.ID); err == nil { + t.Error("expected error getting deleted task, got nil") + } + + // Verify subtasks are gone + pid := parent.ID + subs, _ := svc.ListTasks(ctx, service.TaskFilter{ParentID: &pid}) + if len(subs) != 0 { + t.Errorf("expected 0 subtasks after cascade delete, got %d", len(subs)) + } +} + +func TestREST_Board(t *testing.T) { + ts, svc, _ := newTestAPI(t) + ctx := context.Background() + + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "T1", Status: db.StatusInbox}) //nolint:errcheck + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "T2", Status: db.StatusDesign}) //nolint:errcheck + + resp, err := http.Get(ts.URL + "/api/board") + if err != nil { + t.Fatalf("GET /api/board: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var board kanbanapi.Board + json.NewDecoder(resp.Body).Decode(&board) //nolint:errcheck + if len(board.Columns) != len(db.StatusWorkflow) { + t.Errorf("expected %d columns, got %d", len(db.StatusWorkflow), len(board.Columns)) + } + inboxCount := 0 + for _, col := range board.Columns { + if col.Status == "Inbox" { + inboxCount = len(col.Tasks) + } + } + if inboxCount != 1 { + t.Errorf("expected 1 task in Inbox, got %d", inboxCount) + } +} + +func TestREST_SSE_AfterMutation(t *testing.T) { + ts, _, _ := newTestAPI(t) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/events", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /events: %v", err) + } + defer resp.Body.Close() + + events := make(chan string, 8) + go func() { + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + events <- strings.TrimPrefix(line, "data: ") + } + } + }() + + // Consume the initial snapshot + select { + case <-events: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for initial snapshot") + } + + // POST a new task to trigger a board_update broadcast + body := `{"title":"SSE Test","status":"Inbox"}` + postResp, err := http.Post(ts.URL+"/api/tasks", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/tasks: %v", err) + } + postResp.Body.Close() + + // Wait for board_update event + select { + case data := <-events: + if !strings.Contains(data, "board_update") { + t.Errorf("expected board_update in SSE data, got: %q", data) + } + case <-time.After(2 * time.Second): + t.Error("timeout waiting for SSE board_update event") + } +} diff --git a/go/plugins/kanban-mcp/internal/config/config.go b/go/plugins/kanban-mcp/internal/config/config.go new file mode 100644 index 000000000..51360b0f3 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/config/config.go @@ -0,0 +1,62 @@ +package config + +import ( + "flag" + "os" +) + +// DBType represents the database backend type. +type DBType string + +const ( + DBTypeSQLite DBType = "sqlite" + DBTypePostgres DBType = "postgres" +) + +// Config holds all runtime settings for the kanban-mcp server. +type Config struct { + Addr string // --addr / KANBAN_ADDR, default ":8080" + Transport string // --transport / KANBAN_TRANSPORT, "http" | "stdio" + DBType DBType // --db-type / KANBAN_DB_TYPE, "sqlite" | "postgres" + DBPath string // --db-path / KANBAN_DB_PATH, default "./kanban.db" + DBURL string // --db-url / KANBAN_DB_URL + LogLevel string // --log-level / KANBAN_LOG_LEVEL, default "info" +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// Load parses CLI flags (os.Args[1:]) with KANBAN_* environment variable fallback. +func Load() (*Config, error) { + return LoadArgs(os.Args[1:]) +} + +// LoadArgs parses the given args with KANBAN_* environment variable fallback. +// Separated from Load to allow testability without global flag state. +func LoadArgs(args []string) (*Config, error) { + fs := flag.NewFlagSet("kanban-mcp", flag.ContinueOnError) + + addr := fs.String("addr", envOrDefault("KANBAN_ADDR", ":8080"), "listen address") + transport := fs.String("transport", envOrDefault("KANBAN_TRANSPORT", "http"), "transport mode: http or stdio") + dbType := fs.String("db-type", envOrDefault("KANBAN_DB_TYPE", "sqlite"), "database type: sqlite or postgres") + dbPath := fs.String("db-path", envOrDefault("KANBAN_DB_PATH", "./kanban.db"), "SQLite database file path") + dbURL := fs.String("db-url", envOrDefault("KANBAN_DB_URL", ""), "Postgres connection URL") + logLevel := fs.String("log-level", envOrDefault("KANBAN_LOG_LEVEL", "info"), "log level: debug, info, warn, error") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + return &Config{ + Addr: *addr, + Transport: *transport, + DBType: DBType(*dbType), + DBPath: *dbPath, + DBURL: *dbURL, + LogLevel: *logLevel, + }, nil +} diff --git a/go/plugins/kanban-mcp/internal/config/config_test.go b/go/plugins/kanban-mcp/internal/config/config_test.go new file mode 100644 index 000000000..f94fed4b4 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/config/config_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoad_Defaults(t *testing.T) { + // Clear any env vars that could interfere + for _, key := range []string{"KANBAN_ADDR", "KANBAN_TRANSPORT", "KANBAN_DB_TYPE", "KANBAN_DB_PATH", "KANBAN_DB_URL", "KANBAN_LOG_LEVEL"} { + os.Unsetenv(key) + } + + cfg, err := LoadArgs([]string{}) + if err != nil { + t.Fatalf("LoadArgs() error = %v", err) + } + + tests := []struct { + name string + got string + want string + }{ + {"Addr", cfg.Addr, ":8080"}, + {"Transport", cfg.Transport, "http"}, + {"DBType", string(cfg.DBType), "sqlite"}, + {"DBPath", cfg.DBPath, "./kanban.db"}, + {"DBURL", cfg.DBURL, ""}, + {"LogLevel", cfg.LogLevel, "info"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("Config.%s = %q, want %q", tt.name, tt.got, tt.want) + } + }) + } +} + +func TestLoad_EnvOverride(t *testing.T) { + os.Setenv("KANBAN_ADDR", ":9090") + defer os.Unsetenv("KANBAN_ADDR") + + cfg, err := LoadArgs([]string{}) + if err != nil { + t.Fatalf("LoadArgs() error = %v", err) + } + + if cfg.Addr != ":9090" { + t.Errorf("Config.Addr = %q, want %q", cfg.Addr, ":9090") + } +} diff --git a/go/plugins/kanban-mcp/internal/db/db_test.go b/go/plugins/kanban-mcp/internal/db/db_test.go new file mode 100644 index 000000000..8a4791efe --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/db_test.go @@ -0,0 +1,65 @@ +package db_test + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" +) + +func TestValidStatus(t *testing.T) { + tests := []struct { + name string + status db.TaskStatus + want bool + }{ + {"Inbox valid", db.StatusInbox, true}, + {"Design valid", db.StatusDesign, true}, + {"Develop valid", db.StatusDevelop, true}, + {"Testing valid", db.StatusTesting, true}, + {"SecurityScan valid", db.StatusSecurityScan, true}, + {"CodeReview valid", db.StatusCodeReview, true}, + {"Documentation valid", db.StatusDocumentation, true}, + {"Done valid", db.StatusDone, true}, + {"empty invalid", db.TaskStatus(""), false}, + {"unknown invalid", db.TaskStatus("invalid"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := db.ValidStatus(tt.status); got != tt.want { + t.Errorf("ValidStatus(%q) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +func TestNewManager_Sqlite(t *testing.T) { + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: "file::memory:?cache=shared", + } + mgr, err := db.NewManager(cfg) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + if !mgr.DB().Migrator().HasTable(&db.Task{}) { + t.Error("tasks table does not exist after AutoMigrate") + } + if !mgr.DB().Migrator().HasTable(&db.Attachment{}) { + t.Error("attachments table does not exist after AutoMigrate") + } +} + +func TestNewManager_InvalidType(t *testing.T) { + cfg := &config.Config{ + DBType: config.DBType("invalid"), + } + _, err := db.NewManager(cfg) + if err == nil { + t.Error("NewManager() expected error for invalid DBType, got nil") + } +} diff --git a/go/plugins/kanban-mcp/internal/db/manager.go b/go/plugins/kanban-mcp/internal/db/manager.go new file mode 100644 index 000000000..1f6c27900 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/manager.go @@ -0,0 +1,55 @@ +package db + +import ( + "fmt" + + "github.com/glebarez/sqlite" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Manager handles database connection and initialization. +type Manager struct { + db *gorm.DB +} + +// NewManager creates a new database manager based on the provided config. +func NewManager(cfg *config.Config) (*Manager, error) { + var db *gorm.DB + var err error + + gormCfg := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + TranslateError: true, + } + + switch cfg.DBType { + case config.DBTypeSQLite: + db, err = gorm.Open(sqlite.Open(cfg.DBPath), gormCfg) + case config.DBTypePostgres: + db, err = gorm.Open(postgres.Open(cfg.DBURL), gormCfg) + default: + return nil, fmt.Errorf("invalid database type: %s", cfg.DBType) + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + return &Manager{db: db}, nil +} + +// Initialize runs AutoMigrate for the Task and Attachment models. +func (m *Manager) Initialize() error { + if err := m.db.AutoMigrate(&Task{}, &Attachment{}); err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + return nil +} + +// DB returns the underlying *gorm.DB instance. +func (m *Manager) DB() *gorm.DB { + return m.db +} diff --git a/go/plugins/kanban-mcp/internal/db/models.go b/go/plugins/kanban-mcp/internal/db/models.go new file mode 100644 index 000000000..bfeca1c92 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/db/models.go @@ -0,0 +1,118 @@ +package db + +import ( + "database/sql/driver" + "encoding/json" + "time" +) + +// TaskStatus represents the workflow state of a task. +type TaskStatus string + +const ( + StatusInbox TaskStatus = "Inbox" + StatusDesign TaskStatus = "Design" + StatusDevelop TaskStatus = "Develop" + StatusTesting TaskStatus = "Testing" + StatusSecurityScan TaskStatus = "SecurityScan" + StatusCodeReview TaskStatus = "CodeReview" + StatusDocumentation TaskStatus = "Documentation" + StatusDone TaskStatus = "Done" +) + +// StatusWorkflow defines the ordered workflow for tasks. +var StatusWorkflow = []TaskStatus{ + StatusInbox, + StatusDesign, + StatusDevelop, + StatusTesting, + StatusSecurityScan, + StatusCodeReview, + StatusDocumentation, + StatusDone, +} + +// ValidStatus returns true if s is one of the 8 workflow statuses. +func ValidStatus(s TaskStatus) bool { + for _, v := range StatusWorkflow { + if v == s { + return true + } + } + return false +} + +// StringSlice is a custom type for storing string slices as JSON in the database. +type StringSlice []string + +// Scan implements the sql.Scanner interface for StringSlice. +func (s *StringSlice) Scan(value interface{}) error { + if value == nil { + *s = nil + return nil + } + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + *s = nil + return nil + } + if len(bytes) == 0 || string(bytes) == "null" { + *s = nil + return nil + } + return json.Unmarshal(bytes, s) +} + +// Value implements the driver.Valuer interface for StringSlice. +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + data, err := json.Marshal(s) + if err != nil { + return nil, err + } + return string(data), nil +} + +// Task is the GORM model for a kanban task. +type Task struct { + ID uint `gorm:"primarykey"` + Title string `gorm:"not null"` + Description string + Status TaskStatus `gorm:"not null;default:'Inbox'"` + Assignee string + Labels StringSlice `gorm:"type:text"` + UserInputNeeded bool `gorm:"not null;default:false"` + ParentID *uint + Subtasks []*Task `gorm:"foreignKey:ParentID"` + Attachments []*Attachment `gorm:"foreignKey:TaskID"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// AttachmentType represents the type of attachment. +type AttachmentType string + +const ( + AttachmentTypeFile AttachmentType = "file" + AttachmentTypeLink AttachmentType = "link" +) + +// Attachment is the GORM model for a task attachment. +type Attachment struct { + ID uint `gorm:"primarykey"` + TaskID uint `gorm:"not null;index"` + Type AttachmentType `gorm:"type:varchar(16);not null"` + Filename string `gorm:"type:varchar(255)"` + Content string `gorm:"type:text"` + URL string `gorm:"type:text"` + Title string `gorm:"type:varchar(255)"` + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/go/plugins/kanban-mcp/internal/mcp/tools.go b/go/plugins/kanban-mcp/internal/mcp/tools.go new file mode 100644 index 000000000..57892d4e1 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/mcp/tools.go @@ -0,0 +1,378 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Board is the response for get_board, grouping tasks by status column. +type Board struct { + Columns []Column `json:"columns"` +} + +// Column holds tasks for a single status in the workflow. +type Column struct { + Status string `json:"status"` + Tasks []*db.Task `json:"tasks"` +} + +// NewServer creates and returns an MCP server with all 12 Kanban tools registered. +func NewServer(svc *service.TaskService) *mcpsdk.Server { + server := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "kanban", + Version: "v1.0.0", + }, nil) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_tasks", + Description: "List tasks, optionally filtered by status, assignee, or label. Returns top-level tasks only by default.", + }, handleListTasks(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_task", + Description: "Get a task by ID including its subtasks and attachments.", + }, handleGetTask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "create_task", + Description: "Create a new top-level task. Status defaults to Inbox if not specified.", + }, handleCreateTask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "create_subtask", + Description: "Create a subtask under an existing top-level task (one level only).", + }, handleCreateSubtask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "assign_task", + Description: "Assign a task to a person. Pass empty string to clear assignment.", + }, handleAssignTask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "move_task", + Description: "Move a task to a new status column. Valid statuses: Inbox, Design, Develop, Testing, SecurityScan, CodeReview, Documentation, Done.", + }, handleMoveTask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "update_task", + Description: "Update task fields (title, description, status, assignee, labels, user_input_needed).", + }, handleUpdateTask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "set_user_input_needed", + Description: "Set or clear the user_input_needed flag on a task.", + }, handleSetUserInputNeeded(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_task", + Description: "Delete a task and all its subtasks and attachments.", + }, handleDeleteTask(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_board", + Description: "Get the full Kanban board grouped by status columns in workflow order, with subtasks and attachments inline.", + }, handleGetBoard(svc)) + + // Attachment tools + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "add_attachment", + Description: "Add a file or link attachment to a top-level task. For type=file: provide filename and content. For type=link: provide url and optional title.", + }, handleAddAttachment(svc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "delete_attachment", + Description: "Delete an attachment by ID.", + }, handleDeleteAttachment(svc)) + + return server +} + +// textResult wraps a value as a JSON text content result. +func textResult(v interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + data, err := json.Marshal(v) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + } + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: string(data)}, + }, + }, nil, nil +} + +// errorResult returns an MCP error result with isError=true. +func errorResult(msg string) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: msg}, + }, + } +} + +// --- Tool input types --- + +type listTasksInput struct { + Status string `json:"status,omitempty"` + Assignee string `json:"assignee,omitempty"` + Label string `json:"label,omitempty"` +} + +type getTaskInput struct { + ID uint `json:"id"` +} + +type createTaskInput struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +type createSubtaskInput struct { + ParentID uint `json:"parent_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +type assignTaskInput struct { + ID uint `json:"id"` + Assignee string `json:"assignee"` +} + +type moveTaskInput struct { + ID uint `json:"id"` + Status string `json:"status"` +} + +type updateTaskInput struct { + ID uint `json:"id"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Status *string `json:"status,omitempty"` + Assignee *string `json:"assignee,omitempty"` + Labels *[]string `json:"labels,omitempty"` + UserInputNeeded *bool `json:"user_input_needed,omitempty"` +} + +type setUserInputNeededInput struct { + ID uint `json:"id"` + Needed bool `json:"needed"` +} + +type deleteTaskInput struct { + ID uint `json:"id"` +} + +type addAttachmentInput struct { + TaskID uint `json:"task_id"` + Type string `json:"type"` + Filename string `json:"filename,omitempty"` + Content string `json:"content,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` +} + +type deleteAttachmentInput struct { + ID uint `json:"id"` +} + +// --- Tool handlers --- + +func handleListTasks(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, listTasksInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input listTasksInput) (*mcpsdk.CallToolResult, interface{}, error) { + filter := service.TaskFilter{} + if input.Status != "" { + s := db.TaskStatus(input.Status) + filter.Status = &s + } + if input.Assignee != "" { + filter.Assignee = &input.Assignee + } + if input.Label != "" { + filter.Label = &input.Label + } + + tasks, err := svc.ListTasks(ctx, filter) + if err != nil { + return errorResult(fmt.Sprintf("list_tasks failed: %v", err)), nil, nil + } + return textResult(tasks) + } +} + +func handleGetTask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, getTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input getTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + task, err := svc.GetTask(ctx, input.ID) + if err != nil { + return errorResult(fmt.Sprintf("get_task failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleCreateTask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, createTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input createTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.CreateTaskRequest{ + Title: input.Title, + Description: input.Description, + Status: db.TaskStatus(input.Status), + Labels: input.Labels, + } + task, err := svc.CreateTask(ctx, req) + if err != nil { + return errorResult(fmt.Sprintf("create_task failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleCreateSubtask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, createSubtaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input createSubtaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.CreateTaskRequest{ + Title: input.Title, + Description: input.Description, + Status: db.TaskStatus(input.Status), + Labels: input.Labels, + } + task, err := svc.CreateSubtask(ctx, input.ParentID, req) + if err != nil { + return errorResult(fmt.Sprintf("create_subtask failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleAssignTask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, assignTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input assignTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + task, err := svc.AssignTask(ctx, input.ID, input.Assignee) + if err != nil { + return errorResult(fmt.Sprintf("assign_task failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleMoveTask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, moveTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input moveTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + task, err := svc.MoveTask(ctx, input.ID, db.TaskStatus(input.Status)) + if err != nil { + return errorResult(fmt.Sprintf("move_task failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleUpdateTask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, updateTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input updateTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.UpdateTaskRequest{ + Title: input.Title, + Description: input.Description, + Assignee: input.Assignee, + Labels: input.Labels, + UserInputNeeded: input.UserInputNeeded, + } + if input.Status != nil { + s := db.TaskStatus(*input.Status) + req.Status = &s + } + task, err := svc.UpdateTask(ctx, input.ID, req) + if err != nil { + return errorResult(fmt.Sprintf("update_task failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleSetUserInputNeeded(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, setUserInputNeededInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input setUserInputNeededInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.UpdateTaskRequest{ + UserInputNeeded: &input.Needed, + } + task, err := svc.UpdateTask(ctx, input.ID, req) + if err != nil { + return errorResult(fmt.Sprintf("set_user_input_needed failed: %v", err)), nil, nil + } + return textResult(task) + } +} + +func handleDeleteTask(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, deleteTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input deleteTaskInput) (*mcpsdk.CallToolResult, interface{}, error) { + if err := svc.DeleteTask(ctx, input.ID); err != nil { + return errorResult(fmt.Sprintf("delete_task failed: %v", err)), nil, nil + } + return textResult(map[string]interface{}{"deleted": true, "id": input.ID}) + } +} + +func handleGetBoard(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + board, err := buildBoard(ctx, svc) + if err != nil { + return errorResult(fmt.Sprintf("get_board failed: %v", err)), nil, nil + } + return textResult(board) + } +} + +func handleAddAttachment(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, addAttachmentInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input addAttachmentInput) (*mcpsdk.CallToolResult, interface{}, error) { + req := service.CreateAttachmentRequest{ + Type: db.AttachmentType(input.Type), + Filename: input.Filename, + Content: input.Content, + URL: input.URL, + Title: input.Title, + } + attachment, err := svc.AddAttachment(ctx, input.TaskID, req) + if err != nil { + return errorResult(fmt.Sprintf("add_attachment failed: %v", err)), nil, nil + } + return textResult(attachment) + } +} + +func handleDeleteAttachment(svc *service.TaskService) func(context.Context, *mcpsdk.CallToolRequest, deleteAttachmentInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input deleteAttachmentInput) (*mcpsdk.CallToolResult, interface{}, error) { + if err := svc.DeleteAttachment(ctx, input.ID); err != nil { + return errorResult(fmt.Sprintf("delete_attachment failed: %v", err)), nil, nil + } + return textResult(map[string]interface{}{"deleted": true, "id": input.ID}) + } +} + +// buildBoard fetches all top-level tasks and groups them by status column. +func buildBoard(ctx context.Context, svc *service.TaskService) (*Board, error) { + tasks, err := svc.ListTasks(ctx, service.TaskFilter{}) + if err != nil { + return nil, fmt.Errorf("failed to list tasks: %w", err) + } + + // Index tasks by status + byStatus := make(map[db.TaskStatus][]*db.Task) + for _, t := range tasks { + byStatus[t.Status] = append(byStatus[t.Status], t) + } + + columns := make([]Column, 0, len(db.StatusWorkflow)) + for _, status := range db.StatusWorkflow { + col := Column{ + Status: string(status), + Tasks: byStatus[status], + } + if col.Tasks == nil { + col.Tasks = []*db.Task{} + } + columns = append(columns, col) + } + + return &Board{Columns: columns}, nil +} diff --git a/go/plugins/kanban-mcp/internal/mcp/tools_test.go b/go/plugins/kanban-mcp/internal/mcp/tools_test.go new file mode 100644 index 000000000..d2e19fc7e --- /dev/null +++ b/go/plugins/kanban-mcp/internal/mcp/tools_test.go @@ -0,0 +1,304 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + kanbanmcp "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// nopBroadcaster is a no-op Broadcaster for testing. +type nopBroadcaster struct{} + +func (n *nopBroadcaster) Broadcast(_ interface{}) {} + +// setupTest creates an in-memory SQLite db and returns a connected MCP client session. +func setupTest(t *testing.T) (*mcpsdk.ClientSession, func()) { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.db") + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: dbPath, + } + mgr, err := db.NewManager(cfg) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + + svc := service.NewTaskService(mgr.DB(), &nopBroadcaster{}) + server := kanbanmcp.NewServer(svc) + + ctx := context.Background() + st, ct := mcpsdk.NewInMemoryTransports() + + _, err = server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + + return cs, func() { cs.Close() } +} + +// callTool is a helper to call an MCP tool and return the text content. +func callTool(t *testing.T, cs *mcpsdk.ClientSession, name string, args map[string]interface{}) *mcpsdk.CallToolResult { + t.Helper() + ctx := context.Background() + result, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: name, + Arguments: args, + }) + if err != nil { + t.Fatalf("CallTool(%s): %v", name, err) + } + return result +} + +// extractText returns the text from the first TextContent item of a result. +func extractText(t *testing.T, result *mcpsdk.CallToolResult) string { + t.Helper() + if len(result.Content) == 0 { + t.Fatal("result has no content") + } + tc, ok := result.Content[0].(*mcpsdk.TextContent) + if !ok { + t.Fatalf("content[0] is not *TextContent") + } + return tc.Text +} + +func TestMCPTool_CreateTask(t *testing.T) { + cs, cleanup := setupTest(t) + defer cleanup() + + result := callTool(t, cs, "create_task", map[string]interface{}{ + "title": "Fix bug", + "status": "Design", + }) + + if result.IsError { + t.Fatalf("create_task returned error: %s", extractText(t, result)) + } + + var task db.Task + if err := json.Unmarshal([]byte(extractText(t, result)), &task); err != nil { + t.Fatalf("unmarshal task: %v", err) + } + + if task.Title != "Fix bug" { + t.Errorf("title = %q, want %q", task.Title, "Fix bug") + } + if task.Status != db.StatusDesign { + t.Errorf("status = %q, want %q", task.Status, db.StatusDesign) + } + if task.ID == 0 { + t.Error("task.ID should be non-zero") + } +} + +func TestMCPTool_MoveTask_Invalid(t *testing.T) { + cs, cleanup := setupTest(t) + defer cleanup() + + // Create a task first + createResult := callTool(t, cs, "create_task", map[string]interface{}{ + "title": "Some task", + }) + if createResult.IsError { + t.Fatalf("create_task failed: %s", extractText(t, createResult)) + } + + var task db.Task + if err := json.Unmarshal([]byte(extractText(t, createResult)), &task); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Move to invalid status + moveResult := callTool(t, cs, "move_task", map[string]interface{}{ + "id": task.ID, + "status": "INVALID", + }) + + if !moveResult.IsError { + t.Error("move_task with invalid status should return isError:true") + } +} + +func TestMCPTool_CreateSubtask(t *testing.T) { + cs, cleanup := setupTest(t) + defer cleanup() + + // Create parent task + parentResult := callTool(t, cs, "create_task", map[string]interface{}{ + "title": "Parent task", + }) + if parentResult.IsError { + t.Fatalf("create_task failed: %s", extractText(t, parentResult)) + } + + var parent db.Task + if err := json.Unmarshal([]byte(extractText(t, parentResult)), &parent); err != nil { + t.Fatalf("unmarshal parent: %v", err) + } + + // Create subtask + subResult := callTool(t, cs, "create_subtask", map[string]interface{}{ + "parent_id": parent.ID, + "title": "Subtask one", + }) + if subResult.IsError { + t.Fatalf("create_subtask failed: %s", extractText(t, subResult)) + } + + var subtask db.Task + if err := json.Unmarshal([]byte(extractText(t, subResult)), &subtask); err != nil { + t.Fatalf("unmarshal subtask: %v", err) + } + + if subtask.ParentID == nil { + t.Fatal("subtask.ParentID should not be nil") + } + if *subtask.ParentID != parent.ID { + t.Errorf("subtask.ParentID = %d, want %d", *subtask.ParentID, parent.ID) + } +} + +func TestMCPTool_AssignTask(t *testing.T) { + cs, cleanup := setupTest(t) + defer cleanup() + + // Create task + createResult := callTool(t, cs, "create_task", map[string]interface{}{ + "title": "Assign me", + }) + if createResult.IsError { + t.Fatalf("create_task failed") + } + + var task db.Task + if err := json.Unmarshal([]byte(extractText(t, createResult)), &task); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Assign + assignResult := callTool(t, cs, "assign_task", map[string]interface{}{ + "id": task.ID, + "assignee": "alice", + }) + if assignResult.IsError { + t.Fatalf("assign_task failed: %s", extractText(t, assignResult)) + } + + var updated db.Task + if err := json.Unmarshal([]byte(extractText(t, assignResult)), &updated); err != nil { + t.Fatalf("unmarshal updated: %v", err) + } + + if updated.Assignee != "alice" { + t.Errorf("assignee = %q, want %q", updated.Assignee, "alice") + } +} + +func TestMCPTool_DeleteTask_Cascade(t *testing.T) { + cs, cleanup := setupTest(t) + defer cleanup() + + // Create parent + parentResult := callTool(t, cs, "create_task", map[string]interface{}{ + "title": "Parent", + }) + if parentResult.IsError { + t.Fatalf("create parent failed") + } + var parent db.Task + if err := json.Unmarshal([]byte(extractText(t, parentResult)), &parent); err != nil { + t.Fatalf("unmarshal parent: %v", err) + } + + // Create subtask + subResult := callTool(t, cs, "create_subtask", map[string]interface{}{ + "parent_id": parent.ID, + "title": "Child", + }) + if subResult.IsError { + t.Fatalf("create subtask failed: %s", extractText(t, subResult)) + } + + // Delete parent + deleteResult := callTool(t, cs, "delete_task", map[string]interface{}{ + "id": parent.ID, + }) + if deleteResult.IsError { + t.Fatalf("delete_task failed: %s", extractText(t, deleteResult)) + } + + // Verify parent is gone + getResult := callTool(t, cs, "get_task", map[string]interface{}{ + "id": parent.ID, + }) + if !getResult.IsError { + t.Error("get_task after delete should return error") + } +} + +func TestMCPTool_GetBoard(t *testing.T) { + cs, cleanup := setupTest(t) + defer cleanup() + + // Create tasks in different statuses + for _, args := range []map[string]interface{}{ + {"title": "Task A", "status": "Inbox"}, + {"title": "Task B", "status": "Design"}, + {"title": "Task C", "status": "Develop"}, + } { + r := callTool(t, cs, "create_task", args) + if r.IsError { + t.Fatalf("create_task failed: %s", extractText(t, r)) + } + } + + boardResult := callTool(t, cs, "get_board", map[string]interface{}{}) + if boardResult.IsError { + t.Fatalf("get_board failed: %s", extractText(t, boardResult)) + } + + var board kanbanmcp.Board + if err := json.Unmarshal([]byte(extractText(t, boardResult)), &board); err != nil { + t.Fatalf("unmarshal board: %v", err) + } + + if len(board.Columns) != len(db.StatusWorkflow) { + t.Errorf("board has %d columns, want %d", len(board.Columns), len(db.StatusWorkflow)) + } + + // Verify tasks are in the right columns + found := map[string]int{} + for _, col := range board.Columns { + found[col.Status] = len(col.Tasks) + } + + if found["Inbox"] != 1 { + t.Errorf("Inbox column has %d tasks, want 1", found["Inbox"]) + } + if found["Design"] != 1 { + t.Errorf("Design column has %d tasks, want 1", found["Design"]) + } + if found["Develop"] != 1 { + t.Errorf("Develop column has %d tasks, want 1", found["Develop"]) + } +} diff --git a/go/plugins/kanban-mcp/internal/service/postgres_integration_test.go b/go/plugins/kanban-mcp/internal/service/postgres_integration_test.go new file mode 100644 index 000000000..917e6ff32 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/postgres_integration_test.go @@ -0,0 +1,140 @@ +package service_test + +import ( + "context" + "os" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" +) + +// TestPostgres_Integration runs a full CRUD + subtask + assign workflow against +// a real Postgres database. It is skipped unless KANBAN_TEST_POSTGRES_URL is set. +// +// Example: +// +// KANBAN_TEST_POSTGRES_URL="host=localhost user=postgres password=test dbname=postgres port=5432 sslmode=disable" \ +// go test ./cmd/kanban-mcp/internal/service/... -run TestPostgres_Integration -v +func TestPostgres_Integration(t *testing.T) { + pgURL := os.Getenv("KANBAN_TEST_POSTGRES_URL") + if pgURL == "" { + t.Skip("KANBAN_TEST_POSTGRES_URL not set; skipping Postgres integration test") + } + + cfg := &config.Config{ + DBType: config.DBTypePostgres, + DBURL: pgURL, + } + + mgr, err := db.NewManager(cfg) + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + + // Clean up any leftover rows from a previous run. + mgr.DB().Exec("DELETE FROM tasks") + + svc := service.NewTaskService(mgr.DB(), &mockBroadcaster{}) + ctx := context.Background() + + // ---- CreateTask ---- + task, err := svc.CreateTask(ctx, service.CreateTaskRequest{ + Title: "PG task", + Status: db.StatusDesign, + }) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if task.ID == 0 { + t.Fatal("CreateTask() returned task with ID=0") + } + if task.Status != db.StatusDesign { + t.Errorf("status = %q, want %q", task.Status, db.StatusDesign) + } + + // ---- GetTask ---- + got, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got.Title != "PG task" { + t.Errorf("title = %q, want %q", got.Title, "PG task") + } + + // ---- MoveTask ---- + moved, err := svc.MoveTask(ctx, task.ID, db.StatusDevelop) + if err != nil { + t.Fatalf("MoveTask() error = %v", err) + } + if moved.Status != db.StatusDevelop { + t.Errorf("moved status = %q, want %q", moved.Status, db.StatusDevelop) + } + + // ---- AssignTask ---- + assigned, err := svc.AssignTask(ctx, task.ID, "alice") + if err != nil { + t.Fatalf("AssignTask() error = %v", err) + } + if assigned.Assignee != "alice" { + t.Errorf("assignee = %q, want %q", assigned.Assignee, "alice") + } + + // ---- CreateSubtask ---- + sub, err := svc.CreateSubtask(ctx, task.ID, service.CreateTaskRequest{ + Title: "PG subtask", + }) + if err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + if sub.ParentID == nil || *sub.ParentID != task.ID { + t.Errorf("subtask parent_id = %v, want %d", sub.ParentID, task.ID) + } + + // ---- GetTask includes subtasks ---- + withSubs, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask(with subtasks) error = %v", err) + } + if len(withSubs.Subtasks) != 1 { + t.Errorf("subtask count = %d, want 1", len(withSubs.Subtasks)) + } + + // ---- UpdateTask ---- + newTitle := "PG task updated" + updated, err := svc.UpdateTask(ctx, task.ID, service.UpdateTaskRequest{ + Title: &newTitle, + }) + if err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if updated.Title != newTitle { + t.Errorf("updated title = %q, want %q", updated.Title, newTitle) + } + + // ---- ListTasks ---- + tasks, err := svc.ListTasks(ctx, service.TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) == 0 { + t.Error("ListTasks() returned empty slice, expected at least 1 task") + } + + // ---- DeleteTask cascades subtasks ---- + if err := svc.DeleteTask(ctx, task.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + _, err = svc.GetTask(ctx, task.ID) + if err == nil { + t.Error("GetTask() after delete expected error, got nil") + } + _, err = svc.GetTask(ctx, sub.ID) + if err == nil { + t.Error("GetTask(subtask) after cascade delete expected error, got nil") + } +} diff --git a/go/plugins/kanban-mcp/internal/service/task_service.go b/go/plugins/kanban-mcp/internal/service/task_service.go new file mode 100644 index 000000000..0ba463ac2 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/task_service.go @@ -0,0 +1,345 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "gorm.io/gorm" +) + +// TaskFilter defines filters for listing tasks. +type TaskFilter struct { + Status *db.TaskStatus + Assignee *string + Label *string // nil = all labels; set to filter tasks containing this label + ParentID *uint // nil = top-level only (WHERE parent_id IS NULL) +} + +// CreateTaskRequest holds the data for creating a new task. +type CreateTaskRequest struct { + Title string + Description string + Status db.TaskStatus // defaults to StatusInbox if empty + Labels []string +} + +// UpdateTaskRequest holds fields for updating an existing task. +type UpdateTaskRequest struct { + Title *string + Description *string + Status *db.TaskStatus + Assignee *string + Labels *[]string // nil = no change; non-nil replaces existing labels + UserInputNeeded *bool +} + +// CreateAttachmentRequest holds the data for adding an attachment to a task. +type CreateAttachmentRequest struct { + Type db.AttachmentType // "file" or "link" + Filename string // required for type=file + Content string // required for type=file + URL string // required for type=link + Title string // optional for type=link +} + +// Broadcaster is an interface for broadcasting board change events. +type Broadcaster interface { + Broadcast(event interface{}) +} + +// TaskService provides CRUD operations for tasks. +type TaskService struct { + db *gorm.DB + broadcaster Broadcaster +} + +// NewTaskService creates a new TaskService. +func NewTaskService(db *gorm.DB, b Broadcaster) *TaskService { + return &TaskService{db: db, broadcaster: b} +} + +// ListTasks returns tasks matching the filter. +// When filter.ParentID is nil, only top-level tasks (parent_id IS NULL) are returned. +func (s *TaskService) ListTasks(ctx context.Context, filter TaskFilter) ([]*db.Task, error) { + q := s.db.WithContext(ctx) + + if filter.Status != nil { + q = q.Where("status = ?", *filter.Status) + } + if filter.Assignee != nil { + q = q.Where("assignee = ?", *filter.Assignee) + } + if filter.ParentID == nil { + q = q.Where("parent_id IS NULL") + } else { + q = q.Where("parent_id = ?", *filter.ParentID) + } + + var tasks []*db.Task + if err := q.Preload("Subtasks").Preload("Attachments").Find(&tasks).Error; err != nil { + return nil, fmt.Errorf("failed to list tasks: %w", err) + } + + // Apply label filter in-memory (JSON column not easily filterable in SQL across SQLite/Postgres) + if filter.Label != nil { + label := strings.ToLower(*filter.Label) + filtered := make([]*db.Task, 0) + for _, t := range tasks { + for _, l := range t.Labels { + if strings.ToLower(l) == label { + filtered = append(filtered, t) + break + } + } + } + tasks = filtered + } + + return tasks, nil +} + +// GetTask returns a task by ID with its subtasks and attachments preloaded. +// Returns a wrapped gorm.ErrRecordNotFound if the task does not exist. +func (s *TaskService) GetTask(ctx context.Context, id uint) (*db.Task, error) { + var task db.Task + if err := s.db.WithContext(ctx).Preload("Subtasks").Preload("Attachments").First(&task, id).Error; err != nil { + return nil, fmt.Errorf("task %d not found: %w", id, err) + } + return &task, nil +} + +// CreateTask creates a new task. Status defaults to StatusInbox if empty. +func (s *TaskService) CreateTask(ctx context.Context, req CreateTaskRequest) (*db.Task, error) { + status := req.Status + if status == "" { + status = db.StatusInbox + } + + task := &db.Task{ + Title: req.Title, + Description: req.Description, + Status: status, + Labels: deduplicateLabels(req.Labels), + } + + if err := s.db.WithContext(ctx).Create(task).Error; err != nil { + return nil, fmt.Errorf("failed to create task: %w", err) + } + + s.broadcaster.Broadcast(task) + return task, nil +} + +// UpdateTask updates an existing task's fields. +func (s *TaskService) UpdateTask(ctx context.Context, id uint, req UpdateTaskRequest) (*db.Task, error) { + task, err := s.GetTask(ctx, id) + if err != nil { + return nil, err + } + + if req.Title != nil { + task.Title = *req.Title + } + if req.Description != nil { + task.Description = *req.Description + } + if req.Status != nil { + if !db.ValidStatus(*req.Status) { + return nil, fmt.Errorf("invalid status %q: valid statuses are %v", *req.Status, db.StatusWorkflow) + } + task.Status = *req.Status + } + if req.Assignee != nil { + task.Assignee = *req.Assignee + } + if req.Labels != nil { + task.Labels = deduplicateLabels(*req.Labels) + } + if req.UserInputNeeded != nil { + task.UserInputNeeded = *req.UserInputNeeded + } + + if err := s.db.WithContext(ctx).Save(task).Error; err != nil { + return nil, fmt.Errorf("failed to update task %d: %w", id, err) + } + + s.broadcaster.Broadcast(task) + return task, nil +} + +// MoveTask changes a task's status. Returns error for invalid status without writing to DB. +func (s *TaskService) MoveTask(ctx context.Context, id uint, status db.TaskStatus) (*db.Task, error) { + if !db.ValidStatus(status) { + return nil, fmt.Errorf("invalid status %q: valid statuses are %v", status, db.StatusWorkflow) + } + + task, err := s.GetTask(ctx, id) + if err != nil { + return nil, err + } + + task.Status = status + if err := s.db.WithContext(ctx).Save(task).Error; err != nil { + return nil, fmt.Errorf("failed to move task %d: %w", id, err) + } + + s.broadcaster.Broadcast(task) + return task, nil +} + +// AssignTask sets the assignee for a task. An empty string clears the assignment. +func (s *TaskService) AssignTask(ctx context.Context, id uint, assignee string) (*db.Task, error) { + task, err := s.GetTask(ctx, id) + if err != nil { + return nil, err + } + + task.Assignee = assignee + if err := s.db.WithContext(ctx).Save(task).Error; err != nil { + return nil, fmt.Errorf("failed to assign task %d: %w", id, err) + } + + s.broadcaster.Broadcast(task) + return task, nil +} + +// CreateSubtask creates a new subtask under parentID. +// Returns an error if the parent does not exist or is itself a subtask (one-level nesting only). +func (s *TaskService) CreateSubtask(ctx context.Context, parentID uint, req CreateTaskRequest) (*db.Task, error) { + parent, err := s.GetTask(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("parent task %d not found: %w", parentID, err) + } + if parent.ParentID != nil { + return nil, fmt.Errorf("subtasks cannot have subtasks") + } + + status := req.Status + if status == "" { + status = db.StatusInbox + } + + task := &db.Task{ + Title: req.Title, + Description: req.Description, + Status: status, + Labels: deduplicateLabels(req.Labels), + ParentID: &parentID, + } + + if err := s.db.WithContext(ctx).Create(task).Error; err != nil { + return nil, fmt.Errorf("failed to create subtask: %w", err) + } + + s.broadcaster.Broadcast(task) + return task, nil +} + +// DeleteTask deletes a task and all its subtasks and attachments. +func (s *TaskService) DeleteTask(ctx context.Context, id uint) error { + if _, err := s.GetTask(ctx, id); err != nil { + return err + } + + // Delete attachments on the task + if err := s.db.WithContext(ctx).Where("task_id = ?", id).Delete(&db.Attachment{}).Error; err != nil { + return fmt.Errorf("failed to delete attachments of task %d: %w", id, err) + } + + // Delete attachments on subtasks + var subtaskIDs []uint + s.db.WithContext(ctx).Model(&db.Task{}).Where("parent_id = ?", id).Pluck("id", &subtaskIDs) + if len(subtaskIDs) > 0 { + if err := s.db.WithContext(ctx).Where("task_id IN ?", subtaskIDs).Delete(&db.Attachment{}).Error; err != nil { + return fmt.Errorf("failed to delete subtask attachments of task %d: %w", id, err) + } + } + + // Delete subtasks + if err := s.db.WithContext(ctx).Where("parent_id = ?", id).Delete(&db.Task{}).Error; err != nil { + return fmt.Errorf("failed to delete subtasks of task %d: %w", id, err) + } + + // Delete the task itself + if err := s.db.WithContext(ctx).Delete(&db.Task{}, id).Error; err != nil { + return fmt.Errorf("failed to delete task %d: %w", id, err) + } + + s.broadcaster.Broadcast(nil) + return nil +} + +// AddAttachment adds an attachment to a top-level task. +// Returns an error if the task is a subtask or if validation fails. +func (s *TaskService) AddAttachment(ctx context.Context, taskID uint, req CreateAttachmentRequest) (*db.Attachment, error) { + task, err := s.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + if task.ParentID != nil { + return nil, fmt.Errorf("attachments can only be added to top-level tasks") + } + + switch req.Type { + case db.AttachmentTypeFile: + if req.Filename == "" || req.Content == "" { + return nil, fmt.Errorf("filename and content required for file attachments") + } + case db.AttachmentTypeLink: + if req.URL == "" { + return nil, fmt.Errorf("url required for link attachments") + } + default: + return nil, fmt.Errorf("type must be 'file' or 'link'") + } + + attachment := &db.Attachment{ + TaskID: taskID, + Type: req.Type, + Filename: req.Filename, + Content: req.Content, + URL: req.URL, + Title: req.Title, + } + + if err := s.db.WithContext(ctx).Create(attachment).Error; err != nil { + return nil, fmt.Errorf("failed to create attachment: %w", err) + } + + s.broadcaster.Broadcast(attachment) + return attachment, nil +} + +// DeleteAttachment deletes an attachment by ID. +func (s *TaskService) DeleteAttachment(ctx context.Context, id uint) error { + var attachment db.Attachment + if err := s.db.WithContext(ctx).First(&attachment, id).Error; err != nil { + return fmt.Errorf("attachment %d not found: %w", id, err) + } + + if err := s.db.WithContext(ctx).Delete(&attachment).Error; err != nil { + return fmt.Errorf("failed to delete attachment %d: %w", id, err) + } + + s.broadcaster.Broadcast(nil) + return nil +} + +// deduplicateLabels removes duplicate labels while preserving order. +func deduplicateLabels(labels []string) db.StringSlice { + if labels == nil { + return nil + } + seen := make(map[string]struct{}) + result := make(db.StringSlice, 0, len(labels)) + for _, l := range labels { + lower := strings.ToLower(l) + if _, ok := seen[lower]; !ok { + seen[lower] = struct{}{} + result = append(result, l) + } + } + return result +} diff --git a/go/plugins/kanban-mcp/internal/service/task_service_test.go b/go/plugins/kanban-mcp/internal/service/task_service_test.go new file mode 100644 index 000000000..f03734219 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/service/task_service_test.go @@ -0,0 +1,635 @@ +package service_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "gorm.io/gorm" +) + +// mockBroadcaster records Broadcast calls. +type mockBroadcaster struct { + calls int +} + +func (m *mockBroadcaster) Broadcast(_ interface{}) { + m.calls++ +} + +// openTestDB opens a fresh SQLite DB and auto-migrates the Task and Attachment models. +func openTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{TranslateError: true}) + if err != nil { + t.Fatalf("openTestDB: %v", err) + } + if err := gormDB.AutoMigrate(&db.Task{}, &db.Attachment{}); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } + return gormDB +} + +func TestCreateTask_Defaults(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + task, err := svc.CreateTask(context.Background(), service.CreateTaskRequest{Title: "No Status"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if task.Status != db.StatusInbox { + t.Errorf("Status = %q, want %q", task.Status, db.StatusInbox) + } +} + +func TestCreateTask_WithStatus(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + task, err := svc.CreateTask(context.Background(), service.CreateTaskRequest{ + Title: "Design Task", + Status: db.StatusDesign, + }) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if task.Status != db.StatusDesign { + t.Errorf("Status = %q, want %q", task.Status, db.StatusDesign) + } +} + +func TestCreateTask_WithLabels(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + task, err := svc.CreateTask(context.Background(), service.CreateTaskRequest{ + Title: "Labeled Task", + Labels: []string{"priority:high", "team:platform"}, + }) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + if len(task.Labels) != 2 { + t.Errorf("Labels count = %d, want 2", len(task.Labels)) + } + if task.Labels[0] != "priority:high" || task.Labels[1] != "team:platform" { + t.Errorf("Labels = %v, want [priority:high, team:platform]", task.Labels) + } + + // Verify labels persist after re-fetch + fetched, err := svc.GetTask(context.Background(), task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(fetched.Labels) != 2 { + t.Errorf("Fetched Labels count = %d, want 2", len(fetched.Labels)) + } +} + +func TestGetTask_NotFound(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + _, err := svc.GetTask(context.Background(), 9999) + if err == nil { + t.Fatal("GetTask() expected error for non-existent task, got nil") + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("GetTask() error = %v, want wrapped gorm.ErrRecordNotFound", err) + } +} + +func TestMoveTask_Valid(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, err := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Move me"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + moved, err := svc.MoveTask(ctx, task.ID, db.StatusDevelop) + if err != nil { + t.Fatalf("MoveTask() error = %v", err) + } + if moved.Status != db.StatusDevelop { + t.Errorf("Status = %q, want %q", moved.Status, db.StatusDevelop) + } +} + +func TestMoveTask_InvalidStatus(t *testing.T) { + b := &mockBroadcaster{} + svc := service.NewTaskService(openTestDB(t), b) + ctx := context.Background() + + task, err := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Move me"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + callsBefore := b.calls + + _, err = svc.MoveTask(ctx, task.ID, db.TaskStatus("INVALID")) + if err == nil { + t.Fatal("MoveTask() expected error for invalid status, got nil") + } + if b.calls != callsBefore { + t.Error("Broadcast must not be called on invalid status") + } +} + +func TestListTasks_Filter(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Inbox 1"}) + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Inbox 2"}) + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Design 1", Status: db.StatusDesign}) + + status := db.StatusInbox + tasks, err := svc.ListTasks(ctx, service.TaskFilter{Status: &status}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) != 2 { + t.Errorf("ListTasks(Inbox) = %d tasks, want 2", len(tasks)) + } +} + +func TestListTasks_LabelFilter(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task A", Labels: []string{"priority:high", "team:platform"}}) + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task B", Labels: []string{"priority:low"}}) + svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task C", Labels: []string{"priority:high", "team:infra"}}) + + label := "priority:high" + tasks, err := svc.ListTasks(ctx, service.TaskFilter{Label: &label}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) != 2 { + t.Errorf("ListTasks(priority:high) = %d tasks, want 2", len(tasks)) + } +} + +func TestDeleteTask_Simple(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, err := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Delete me"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + if err := svc.DeleteTask(ctx, task.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + + _, err = svc.GetTask(ctx, task.ID) + if err == nil { + t.Fatal("GetTask() expected error after deletion, got nil") + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("GetTask() error = %v, want wrapped gorm.ErrRecordNotFound", err) + } +} + +func TestBroadcast_CalledOnMutation(t *testing.T) { + b := &mockBroadcaster{} + svc := service.NewTaskService(openTestDB(t), b) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Broadcast test"}) + if b.calls != 1 { + t.Errorf("after CreateTask: Broadcast calls = %d, want 1", b.calls) + } + + title := "Updated" + svc.UpdateTask(ctx, task.ID, service.UpdateTaskRequest{Title: &title}) + if b.calls != 2 { + t.Errorf("after UpdateTask: Broadcast calls = %d, want 2", b.calls) + } + + svc.MoveTask(ctx, task.ID, db.StatusDesign) + if b.calls != 3 { + t.Errorf("after MoveTask: Broadcast calls = %d, want 3", b.calls) + } + + svc.DeleteTask(ctx, task.ID) + if b.calls != 4 { + t.Errorf("after DeleteTask: Broadcast calls = %d, want 4", b.calls) + } +} + +func TestAssignTask(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, err := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Assign me"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + // Assign to alice + assigned, err := svc.AssignTask(ctx, task.ID, "alice") + if err != nil { + t.Fatalf("AssignTask() error = %v", err) + } + if assigned.Assignee != "alice" { + t.Errorf("Assignee = %q, want %q", assigned.Assignee, "alice") + } + + // Reassign to bob + reassigned, err := svc.AssignTask(ctx, task.ID, "bob") + if err != nil { + t.Fatalf("AssignTask() reassign error = %v", err) + } + if reassigned.Assignee != "bob" { + t.Errorf("Assignee = %q, want %q", reassigned.Assignee, "bob") + } + + // Clear assignment + cleared, err := svc.AssignTask(ctx, task.ID, "") + if err != nil { + t.Fatalf("AssignTask() clear error = %v", err) + } + if cleared.Assignee != "" { + t.Errorf("Assignee = %q, want empty string", cleared.Assignee) + } +} + +func TestListTasks_AssigneeFilter(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task1, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Alice task 1"}) + task2, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Alice task 2"}) + task3, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Bob task"}) + svc.AssignTask(ctx, task1.ID, "alice") + svc.AssignTask(ctx, task2.ID, "alice") + svc.AssignTask(ctx, task3.ID, "bob") + + alice := "alice" + tasks, err := svc.ListTasks(ctx, service.TaskFilter{Assignee: &alice}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + if len(tasks) != 2 { + t.Errorf("ListTasks(alice) = %d tasks, want 2", len(tasks)) + } +} + +func TestCreateSubtask_Valid(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + parent, err := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + if err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + + sub, err := svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Child"}) + if err != nil { + t.Fatalf("CreateSubtask() error = %v", err) + } + if sub.ParentID == nil || *sub.ParentID != parent.ID { + t.Errorf("ParentID = %v, want %d", sub.ParentID, parent.ID) + } +} + +func TestCreateSubtask_ParentNotFound(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + _, err := svc.CreateSubtask(context.Background(), 9999, service.CreateTaskRequest{Title: "Orphan"}) + if err == nil { + t.Fatal("CreateSubtask() expected error for non-existent parent, got nil") + } +} + +func TestCreateSubtask_NestedRejection(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + child, _ := svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Child"}) + + _, err := svc.CreateSubtask(ctx, child.ID, service.CreateTaskRequest{Title: "Grandchild"}) + if err == nil { + t.Fatal("CreateSubtask() expected error for nested subtask, got nil") + } + if err.Error() != "subtasks cannot have subtasks" { + t.Errorf("error = %q, want %q", err.Error(), "subtasks cannot have subtasks") + } +} + +func TestDeleteTask_Cascade(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + sub1, _ := svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub 1"}) + sub2, _ := svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub 2"}) + + if err := svc.DeleteTask(ctx, parent.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + + for _, id := range []uint{parent.ID, sub1.ID, sub2.ID} { + _, err := svc.GetTask(ctx, id) + if err == nil { + t.Errorf("GetTask(%d) expected error after cascade delete, got nil", id) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("GetTask(%d) error = %v, want wrapped gorm.ErrRecordNotFound", id, err) + } + } +} + +func TestGetTask_WithSubtasks(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub 1"}) + svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub 2"}) + + fetched, err := svc.GetTask(ctx, parent.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(fetched.Subtasks) != 2 { + t.Errorf("Subtasks count = %d, want 2", len(fetched.Subtasks)) + } +} + +// --- Attachment tests --- + +func TestAddAttachment_File(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task with file"}) + att, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "DESIGN.md", + Content: "# Design\n\nOverview", + }) + if err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if att.Type != db.AttachmentTypeFile { + t.Errorf("Type = %q, want %q", att.Type, db.AttachmentTypeFile) + } + if att.Filename != "DESIGN.md" { + t.Errorf("Filename = %q, want %q", att.Filename, "DESIGN.md") + } + if att.TaskID != task.ID { + t.Errorf("TaskID = %d, want %d", att.TaskID, task.ID) + } +} + +func TestAddAttachment_Link(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task with link"}) + att, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, + URL: "https://example.com", + Title: "Reference", + }) + if err != nil { + t.Fatalf("AddAttachment() error = %v", err) + } + if att.Type != db.AttachmentTypeLink { + t.Errorf("Type = %q, want %q", att.Type, db.AttachmentTypeLink) + } + if att.URL != "https://example.com" { + t.Errorf("URL = %q, want %q", att.URL, "https://example.com") + } +} + +func TestAddAttachment_SubtaskRejected(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + sub, _ := svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Child"}) + + _, err := svc.AddAttachment(ctx, sub.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "test.md", + Content: "content", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for subtask, got nil") + } + if err.Error() != "attachments can only be added to top-level tasks" { + t.Errorf("error = %q, want %q", err.Error(), "attachments can only be added to top-level tasks") + } +} + +func TestAddAttachment_TaskNotFound(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + _, err := svc.AddAttachment(context.Background(), 9999, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "test.md", + Content: "content", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for non-existent task, got nil") + } +} + +func TestAddAttachment_InvalidType(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task"}) + _, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentType("invalid"), + }) + if err == nil { + t.Fatal("AddAttachment() expected error for invalid type, got nil") + } + if err.Error() != "type must be 'file' or 'link'" { + t.Errorf("error = %q, want %q", err.Error(), "type must be 'file' or 'link'") + } +} + +func TestAddAttachment_FileMissingFields(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task"}) + + // Missing filename + _, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Content: "content", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for missing filename, got nil") + } + + // Missing content + _, err = svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "test.md", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for missing content, got nil") + } +} + +func TestAddAttachment_LinkMissingURL(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task"}) + _, err := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, + Title: "No URL", + }) + if err == nil { + t.Fatal("AddAttachment() expected error for missing URL, got nil") + } +} + +func TestDeleteAttachment_Valid(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task"}) + att, _ := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, + Filename: "test.md", + Content: "content", + }) + + if err := svc.DeleteAttachment(ctx, att.ID); err != nil { + t.Fatalf("DeleteAttachment() error = %v", err) + } + + // Verify attachment is gone + fetched, _ := svc.GetTask(ctx, task.ID) + if len(fetched.Attachments) != 0 { + t.Errorf("Attachments count = %d after delete, want 0", len(fetched.Attachments)) + } +} + +func TestDeleteAttachment_NotFound(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + + err := svc.DeleteAttachment(context.Background(), 9999) + if err == nil { + t.Fatal("DeleteAttachment() expected error for non-existent attachment, got nil") + } +} + +func TestDeleteTask_CascadeWithAttachments(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + gormDB := openTestDB(t) + svc = service.NewTaskService(gormDB, &mockBroadcaster{}) + + parent, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Parent"}) + svc.AddAttachment(ctx, parent.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "a.md", Content: "a", + }) + svc.AddAttachment(ctx, parent.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, URL: "https://example.com", + }) + sub, _ := svc.CreateSubtask(ctx, parent.ID, service.CreateTaskRequest{Title: "Sub"}) + + if err := svc.DeleteTask(ctx, parent.ID); err != nil { + t.Fatalf("DeleteTask() error = %v", err) + } + + // Verify parent and subtask gone + for _, id := range []uint{parent.ID, sub.ID} { + _, err := svc.GetTask(ctx, id) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("GetTask(%d) should return not-found after cascade delete", id) + } + } + + // Verify attachments gone + var count int64 + gormDB.Model(&db.Attachment{}).Where("task_id = ?", parent.ID).Count(&count) + if count != 0 { + t.Errorf("Attachments count = %d after cascade delete, want 0", count) + } +} + +func TestGetTask_WithAttachments(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task with attachments"}) + svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "a.md", Content: "content", + }) + svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeLink, URL: "https://example.com", Title: "Link", + }) + + fetched, err := svc.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if len(fetched.Attachments) != 2 { + t.Errorf("Attachments count = %d, want 2", len(fetched.Attachments)) + } +} + +func TestBroadcast_CalledOnAttachmentMutation(t *testing.T) { + b := &mockBroadcaster{} + svc := service.NewTaskService(openTestDB(t), b) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task"}) + callsBefore := b.calls + + att, _ := svc.AddAttachment(ctx, task.ID, service.CreateAttachmentRequest{ + Type: db.AttachmentTypeFile, Filename: "test.md", Content: "content", + }) + if b.calls != callsBefore+1 { + t.Errorf("after AddAttachment: Broadcast calls = %d, want %d", b.calls, callsBefore+1) + } + + svc.DeleteAttachment(ctx, att.ID) + if b.calls != callsBefore+2 { + t.Errorf("after DeleteAttachment: Broadcast calls = %d, want %d", b.calls, callsBefore+2) + } +} + +func TestUpdateTask_Labels(t *testing.T) { + svc := service.NewTaskService(openTestDB(t), &mockBroadcaster{}) + ctx := context.Background() + + task, _ := svc.CreateTask(ctx, service.CreateTaskRequest{Title: "Task"}) + + labels := []string{"priority:high", "group:platform"} + updated, err := svc.UpdateTask(ctx, task.ID, service.UpdateTaskRequest{Labels: &labels}) + if err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if len(updated.Labels) != 2 { + t.Errorf("Labels count = %d, want 2", len(updated.Labels)) + } + + // Verify deduplication + dupeLabels := []string{"a", "A", "b"} + updated, err = svc.UpdateTask(ctx, task.ID, service.UpdateTaskRequest{Labels: &dupeLabels}) + if err != nil { + t.Fatalf("UpdateTask() error = %v", err) + } + if len(updated.Labels) != 2 { + t.Errorf("Labels count after dedup = %d, want 2", len(updated.Labels)) + } +} diff --git a/go/plugins/kanban-mcp/internal/sse/hub.go b/go/plugins/kanban-mcp/internal/sse/hub.go new file mode 100644 index 000000000..df9c17f5a --- /dev/null +++ b/go/plugins/kanban-mcp/internal/sse/hub.go @@ -0,0 +1,117 @@ +package sse + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// subBufferSize is the channel buffer per subscriber. +const subBufferSize = 16 + +// Event represents an SSE event sent to clients. +type Event struct { + Type string `json:"type"` // always "board_update" in v1 + Data interface{} `json:"data"` +} + +// Hub manages SSE subscriber connections and broadcasts events to all of them. +// It implements service.Broadcaster. +type Hub struct { + mu sync.RWMutex + subs map[chan Event]struct{} + lastJSON []byte // last broadcast payload; sent as snapshot to new subscribers +} + +// NewHub creates an empty Hub. +func NewHub() *Hub { + return &Hub{ + subs: make(map[chan Event]struct{}), + } +} + +// Subscribe registers a new subscriber and returns a buffered channel for events. +func (h *Hub) Subscribe() chan Event { + ch := make(chan Event, subBufferSize) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +// Unsubscribe removes the given subscriber channel. +func (h *Hub) Unsubscribe(ch chan Event) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() +} + +// Broadcast wraps data in a board_update Event, stores it as the latest snapshot, +// and non-blockingly delivers it to all current subscribers. +// It implements service.Broadcaster. +func (h *Hub) Broadcast(data interface{}) { + event := Event{Type: "board_update", Data: data} + + eventJSON, err := json.Marshal(event) + + h.mu.Lock() + if err == nil { + h.lastJSON = eventJSON + } + clients := make([]chan Event, 0, len(h.subs)) + for ch := range h.subs { + clients = append(clients, ch) + } + h.mu.Unlock() + + for _, ch := range clients { + select { + case ch <- event: + default: // drop for slow subscribers; non-blocking + } + } +} + +// ServeSSE handles the /events SSE endpoint. +// It sends an initial snapshot of the last broadcast state, then streams subsequent events. +func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + ch := h.Subscribe() + defer h.Unsubscribe(ch) + + // Send initial snapshot (last known board state, or empty object). + h.mu.RLock() + lastJSON := h.lastJSON + h.mu.RUnlock() + + if lastJSON != nil { + fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", lastJSON) + } else { + fmt.Fprintf(w, "event: snapshot\ndata: {}\n\n") + } + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case event := <-ch: + eventJSON, err := json.Marshal(event) + if err != nil { + continue + } + fmt.Fprintf(w, "data: %s\n\n", eventJSON) + flusher.Flush() + } + } +} diff --git a/go/plugins/kanban-mcp/internal/sse/hub_test.go b/go/plugins/kanban-mcp/internal/sse/hub_test.go new file mode 100644 index 000000000..fd834b9f7 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/sse/hub_test.go @@ -0,0 +1,162 @@ +package sse + +import ( + "bufio" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestHub_SubscribeUnsubscribe(t *testing.T) { + h := NewHub() + ch1 := h.Subscribe() + ch2 := h.Subscribe() + ch3 := h.Subscribe() + + // Unsubscribe ch3 before broadcast. + h.Unsubscribe(ch3) + + h.Broadcast("test") + + for i, ch := range []chan Event{ch1, ch2} { + select { + case ev := <-ch: + if ev.Type != "board_update" { + t.Errorf("subscriber %d: expected board_update, got %q", i+1, ev.Type) + } + case <-time.After(200 * time.Millisecond): + t.Errorf("subscriber %d: timed out waiting for event", i+1) + } + } + + // ch3 must not receive anything. + select { + case ev := <-ch3: + t.Errorf("unsubscribed channel received unexpected event: %+v", ev) + case <-time.After(50 * time.Millisecond): + // expected: no event + } +} + +func TestHub_Broadcast_NonBlocking(t *testing.T) { + h := NewHub() + + // Create and fill the slow subscriber's buffer completely. + slow := h.Subscribe() + for i := 0; i < subBufferSize; i++ { + slow <- Event{Type: "prefill"} + } + + fast := h.Subscribe() + + done := make(chan struct{}) + go func() { + h.Broadcast("new-data") + close(done) + }() + + select { + case <-done: + // Good: Broadcast returned without blocking. + case <-time.After(500 * time.Millisecond): + t.Fatal("Broadcast blocked on a slow subscriber") + } + + // The fast subscriber should still receive the event. + select { + case ev := <-fast: + if ev.Type != "board_update" { + t.Errorf("fast: expected board_update, got %q", ev.Type) + } + case <-time.After(200 * time.Millisecond): + t.Error("fast subscriber timed out") + } +} + +func TestHub_ConcurrentSubscribers(t *testing.T) { + h := NewHub() + const N = 50 + + channels := make([]chan Event, N) + var wg sync.WaitGroup + for i := 0; i < N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + channels[i] = h.Subscribe() + }(i) + } + wg.Wait() + + h.Broadcast("concurrent") + + for i, ch := range channels { + select { + case ev := <-ch: + if ev.Type != "board_update" { + t.Errorf("subscriber %d: expected board_update, got %q", i, ev.Type) + } + case <-time.After(500 * time.Millisecond): + t.Errorf("subscriber %d timed out", i) + } + } +} + +func TestServeSSE_Integration(t *testing.T) { + h := NewHub() + + srv := httptest.NewServer(http.HandlerFunc(h.ServeSSE)) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET /events: %v", err) + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Errorf("Content-Type: want text/event-stream, got %q", ct) + } + + lines := make(chan string, 200) + go func() { + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + lines <- scanner.Text() + } + }() + + // Wait for the initial snapshot event. + gotSnapshot := false + deadline := time.After(2 * time.Second) + for !gotSnapshot { + select { + case line := <-lines: + if strings.HasPrefix(line, "event: snapshot") { + gotSnapshot = true + } + case <-deadline: + t.Fatal("timed out waiting for initial snapshot event") + } + } + + // Trigger a mutation broadcast. + h.Broadcast(map[string]string{"title": "integration-test"}) + + // Wait for the board_update data line. + gotUpdate := false + deadline2 := time.After(2 * time.Second) + for !gotUpdate { + select { + case line := <-lines: + if strings.HasPrefix(line, "data:") && strings.Contains(line, "board_update") { + gotUpdate = true + } + case <-deadline2: + t.Fatal("timed out waiting for board_update event") + } + } +} diff --git a/go/plugins/kanban-mcp/internal/ui/embed.go b/go/plugins/kanban-mcp/internal/ui/embed.go new file mode 100644 index 000000000..8a41bef90 --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/embed.go @@ -0,0 +1,17 @@ +package ui + +import ( + _ "embed" + "net/http" +) + +//go:embed index.html +var indexHTML []byte + +// Handler returns an http.Handler that serves the embedded SPA. +func Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) //nolint:errcheck + }) +} diff --git a/go/plugins/kanban-mcp/internal/ui/embed_test.go b/go/plugins/kanban-mcp/internal/ui/embed_test.go new file mode 100644 index 000000000..a2be1474f --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/embed_test.go @@ -0,0 +1,49 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestUI_Embedded verifies that indexHTML is non-empty at init time (catches missing embed file). +func TestUI_Embedded(t *testing.T) { + if len(indexHTML) == 0 { + t.Fatal("indexHTML is empty — embed directive likely failed") + } + if !strings.Contains(string(indexHTML), "Kanban") { + t.Error("indexHTML does not contain 'Kanban'") + } +} + +// TestUI_Handler verifies that GET / returns 200 with text/html content-type and non-empty body. +func TestUI_Handler(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + Handler().ServeHTTP(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/html") { + t.Errorf("expected Content-Type text/html, got %q", ct) + } + body := w.Body.String() + if body == "" { + t.Error("expected non-empty body") + } + if !strings.Contains(body, "Kanban") { + t.Errorf("expected body to contain 'Kanban', got: %q", body[:min(200, len(body))]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/go/plugins/kanban-mcp/internal/ui/index.html b/go/plugins/kanban-mcp/internal/ui/index.html new file mode 100644 index 000000000..a0eba3bef --- /dev/null +++ b/go/plugins/kanban-mcp/internal/ui/index.html @@ -0,0 +1,707 @@ + + + + + +Kanban Board + + + +
+

Kanban Board

+ connecting… +
+
+ + + diff --git a/go/plugins/kanban-mcp/kanban-mcp b/go/plugins/kanban-mcp/kanban-mcp new file mode 100755 index 000000000..ebc5b9f60 Binary files /dev/null and b/go/plugins/kanban-mcp/kanban-mcp differ diff --git a/go/plugins/kanban-mcp/main.go b/go/plugins/kanban-mcp/main.go new file mode 100644 index 000000000..57f53ebd6 --- /dev/null +++ b/go/plugins/kanban-mcp/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + kanbanmcp "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + log.Printf("kanban-mcp config: addr=%s transport=%s db-type=%s db-path=%s log-level=%s", + cfg.Addr, cfg.Transport, cfg.DBType, cfg.DBPath, cfg.LogLevel) + + mgr, err := db.NewManager(cfg) + if err != nil { + log.Fatalf("failed to create database manager: %v", err) + } + if err := mgr.Initialize(); err != nil { + log.Fatalf("failed to initialize database: %v", err) + } + log.Printf("database initialized") + + hub := sse.NewHub() + svc := service.NewTaskService(mgr.DB(), hub) + mcpServer := kanbanmcp.NewServer(svc) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if cfg.Transport == "stdio" { + log.Printf("starting in stdio transport mode") + if err := mcpServer.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + log.Fatalf("MCP stdio server error: %v", err) + } + return + } + + // HTTP mode + srv := NewHTTPServer(cfg, svc, hub) + log.Printf("kanban-mcp listening on %s", cfg.Addr) + + go func() { + <-ctx.Done() + srv.Close() //nolint:errcheck + }() + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } +} diff --git a/go/plugins/kanban-mcp/server.go b/go/plugins/kanban-mcp/server.go new file mode 100644 index 000000000..14d7654ff --- /dev/null +++ b/go/plugins/kanban-mcp/server.go @@ -0,0 +1,35 @@ +package main + +import ( + "net/http" + + kanbanapi "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/api" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + kanbanmcp "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/ui" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NewHTTPServer constructs the HTTP server with all routes wired. +func NewHTTPServer(cfg *config.Config, svc *service.TaskService, hub *sse.Hub) *http.Server { + mcpServer := kanbanmcp.NewServer(svc) + mcpHandler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return mcpServer + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpHandler) + mux.HandleFunc("/events", hub.ServeSSE) + mux.HandleFunc("/api/tasks", kanbanapi.TasksHandler(svc)) + mux.HandleFunc("/api/tasks/", kanbanapi.TaskHandler(svc)) + mux.HandleFunc("/api/attachments/", kanbanapi.AttachmentHandler(svc)) + mux.HandleFunc("/api/board", kanbanapi.BoardHandler(svc)) + mux.Handle("/", ui.Handler()) + + return &http.Server{ + Addr: cfg.Addr, + Handler: mux, + } +} diff --git a/go/plugins/kanban-mcp/server_test.go b/go/plugins/kanban-mcp/server_test.go new file mode 100644 index 000000000..cc908f5f4 --- /dev/null +++ b/go/plugins/kanban-mcp/server_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/db" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/service" + "github.com/kagent-dev/kagent/go/plugins/kanban-mcp/internal/sse" +) + +// newTestServer creates a fully wired HTTP server backed by an in-memory SQLite DB. +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "test.db") + cfg := &config.Config{ + DBType: config.DBTypeSQLite, + DBPath: dbPath, + Addr: ":0", + } + + mgr, err := db.NewManager(cfg) + if err != nil { + t.Fatalf("db.NewManager: %v", err) + } + if err := mgr.Initialize(); err != nil { + t.Fatalf("db.Initialize: %v", err) + } + + hub := sse.NewHub() + svc := service.NewTaskService(mgr.DB(), hub) + srv := NewHTTPServer(cfg, svc, hub) + + return httptest.NewServer(srv.Handler) +} + +// TestHTTPServer_MCP verifies that the /mcp endpoint accepts MCP JSON-RPC requests +// and returns a valid JSON-RPC response (SSE-wrapped by the MCP SDK Streamable HTTP transport). +func TestHTTPServer_MCP(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + // The MCP Streamable HTTP transport requires both Accept types. + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST /mcp: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, raw) + } + + // The response is SSE-formatted: "event: message\ndata: \n\n" + raw, _ := io.ReadAll(resp.Body) + sseData := string(raw) + if !strings.Contains(sseData, "data:") { + t.Fatalf("expected SSE data line, got: %q", sseData) + } + + // Extract the JSON from the SSE data line. + var jsonrpcPayload string + for _, line := range strings.Split(sseData, "\n") { + if strings.HasPrefix(line, "data: ") { + jsonrpcPayload = strings.TrimPrefix(line, "data: ") + break + } + } + if jsonrpcPayload == "" { + t.Fatalf("no data line found in SSE response: %q", sseData) + } + + var result map[string]interface{} + if err := json.Unmarshal([]byte(jsonrpcPayload), &result); err != nil { + t.Fatalf("decode JSON-RPC payload: %v", err) + } + if result["jsonrpc"] != "2.0" { + t.Errorf("expected jsonrpc=2.0, got %v", result["jsonrpc"]) + } + if result["result"] == nil && result["error"] == nil { + t.Error("expected either result or error in JSON-RPC response") + } +} + +// TestHTTPServer_SSE verifies that /events returns an SSE stream with the correct headers +// and delivers an initial snapshot event. +func TestHTTPServer_SSE(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/events", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /events: %v", err) + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Errorf("expected Content-Type text/event-stream, got %q", ct) + } + + // Read enough bytes to capture the initial snapshot line + buf := make([]byte, 512) + n, _ := resp.Body.Read(buf) + data := string(buf[:n]) + + if !strings.Contains(data, "event: snapshot") { + t.Errorf("expected snapshot event in SSE stream, got: %q", data) + } +} + +// TestHTTPServer_NotFound verifies that /api/tasks/{unknown-id} returns 404. +func TestHTTPServer_NotFound(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/tasks/99999") + if err != nil { + t.Fatalf("GET /api/tasks/99999: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +// TestHTTPServer_CORS verifies that /mcp responses include the expected CORS-related headers. +func TestHTTPServer_CORS(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + // OPTIONS preflight check + req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/mcp", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("OPTIONS /mcp: %v", err) + } + defer resp.Body.Close() + + // Accept either 200 or 204 for a preflight; the key test is the MCP endpoint is reachable. + // The MCP SDK sets Content-Type on real POST responses. + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` + postReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(body)) + postReq.Header.Set("Content-Type", "application/json") + postReq.Header.Set("Accept", "application/json, text/event-stream") + postResp, err := http.DefaultClient.Do(postReq) + if err != nil { + t.Fatalf("POST /mcp for CORS test: %v", err) + } + defer postResp.Body.Close() + + ct := postResp.Header.Get("Content-Type") + if ct == "" { + t.Error("expected Content-Type header on /mcp POST response") + } + if postResp.StatusCode != http.StatusOK { + t.Errorf("expected 200 on /mcp POST, got %d", postResp.StatusCode) + } +} diff --git a/go/plugins/nats-activity-feed/Dockerfile b/go/plugins/nats-activity-feed/Dockerfile new file mode 100644 index 000000000..935ec8300 --- /dev/null +++ b/go/plugins/nats-activity-feed/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go/ ./go/ +WORKDIR /app/go +RUN go build -o nats-activity-feed ./plugins/nats-activity-feed + +FROM alpine:3.20 +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /app/go/nats-activity-feed /usr/local/bin/nats-activity-feed +ENTRYPOINT ["nats-activity-feed"] diff --git a/go/plugins/nats-activity-feed/PROMPT.md b/go/plugins/nats-activity-feed/PROMPT.md new file mode 100644 index 000000000..039af437f --- /dev/null +++ b/go/plugins/nats-activity-feed/PROMPT.md @@ -0,0 +1,30 @@ +# PROMPT: NATS Activity Feed + +## Objective + +Build a read-only activity feed that subscribes to NATS `agent.>` and streams agent events to the browser via SSE. Single Go binary at `go/plugins/nats-activity-feed/` with embedded HTML UI. + +## Key Requirements + +1. Go binary following kanban-mcp plugin pattern (`go/plugins/kanban-mcp/`) +2. Subscribe to NATS wildcard `agent.>`, parse `StreamEvent` from `go/adk/pkg/streaming/types.go` +3. Extract agent name + session ID from NATS subject (`agent.{name}.{session}.stream`) +4. SSE hub with ring buffer (last 100 events) — adapt `go/plugins/kanban-mcp/internal/sse/hub.go` +5. Embedded single-file HTML SPA — live scrolling feed, color-coded by event type, auto-reconnect +6. Config: `--nats-addr` (default `nats://localhost:4222`), `--addr` (default `:8090`), `--buffer-size`, `--subject` +7. Dockerfile + Helm chart in `helm/tools/nats-activity-feed/` + +## Acceptance Criteria + +- **Given** agents publish to NATS, **When** user opens browser, **Then** live event feed appears +- **Given** new browser connects, **Then** ring buffer contents sent as initial burst +- **Given** NATS drops, **Then** auto-reconnects without user action +- **Given** no activity, **Then** UI shows "Waiting for activity..." +- **Given** multiple agents active, **Then** events interleaved chronologically + +## Reference + +- Design: `specs/nats-activity-feed/design.md` +- Plan: `specs/nats-activity-feed/plan.md` (6 steps, follow in order) +- Pattern to follow: `go/plugins/kanban-mcp/` (SSE hub, embedded HTML, config, Dockerfile) +- Event types: `go/adk/pkg/streaming/types.go` (import, don't duplicate) diff --git a/go/plugins/nats-activity-feed/go.mod b/go/plugins/nats-activity-feed/go.mod new file mode 100644 index 000000000..51ff5cafb --- /dev/null +++ b/go/plugins/nats-activity-feed/go.mod @@ -0,0 +1,24 @@ +module github.com/kagent-dev/kagent/go/plugins/nats-activity-feed + +go 1.25.7 + +require ( + github.com/kagent-dev/kagent/go/adk v0.0.0 + github.com/nats-io/nats-server/v2 v2.12.4 + github.com/nats-io/nats.go v1.49.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect + github.com/nats-io/jwt/v2 v2.8.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.14.0 // indirect +) + +replace github.com/kagent-dev/kagent/go/adk => ../../adk diff --git a/go/plugins/nats-activity-feed/go.sum b/go/plugins/nats-activity-feed/go.sum new file mode 100644 index 000000000..eb2bc975e --- /dev/null +++ b/go/plugins/nats-activity-feed/go.sum @@ -0,0 +1,25 @@ +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= +github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.12.4 h1:ZnT10v2LU2Xcoiy8ek9X6Se4YG8EuMfIfvAEuFVx1Ts= +github.com/nats-io/nats-server/v2 v2.12.4/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/go/plugins/nats-activity-feed/internal/config/config.go b/go/plugins/nats-activity-feed/internal/config/config.go new file mode 100644 index 000000000..684f7ae83 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "flag" + "os" + "strconv" +) + +// Config holds all configuration for the nats-activity-feed server. +type Config struct { + NATSAddr string + Addr string + BufferSize int + Subject string +} + +// Load parses config from os.Args[1:]. +func Load() (*Config, error) { + return LoadArgs(os.Args[1:]) +} + +// LoadArgs parses config from the given args slice (for testability). +func LoadArgs(args []string) (*Config, error) { + fs := flag.NewFlagSet("nats-activity-feed", flag.ContinueOnError) + + natsAddr := fs.String("nats-addr", envOrDefault("NATS_ADDR", "nats://localhost:4222"), "NATS server address") + addr := fs.String("addr", envOrDefault("ACTIVITY_FEED_ADDR", ":8090"), "HTTP listen address") + bufferSize := fs.Int("buffer-size", envOrDefaultInt("ACTIVITY_FEED_BUFFER", 100), "Ring buffer size for new subscribers") + subject := fs.String("subject", envOrDefault("ACTIVITY_FEED_SUBJECT", "agent.>"), "NATS subject pattern to subscribe to") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + return &Config{ + NATSAddr: *natsAddr, + Addr: *addr, + BufferSize: *bufferSize, + Subject: *subject, + }, nil +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envOrDefaultInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/go/plugins/nats-activity-feed/internal/config/config_test.go b/go/plugins/nats-activity-feed/internal/config/config_test.go new file mode 100644 index 000000000..542ebb282 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/config/config_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "testing" +) + +func TestLoadArgs_Defaults(t *testing.T) { + cfg, err := LoadArgs([]string{}) + if err != nil { + t.Fatalf("LoadArgs() error = %v", err) + } + + tests := []struct { + name string + got string + want string + }{ + {"NATSAddr", cfg.NATSAddr, "nats://localhost:4222"}, + {"Addr", cfg.Addr, ":8090"}, + {"Subject", cfg.Subject, "agent.>"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("Config.%s = %q, want %q", tt.name, tt.got, tt.want) + } + }) + } + + if cfg.BufferSize != 100 { + t.Errorf("Config.BufferSize = %d, want 100", cfg.BufferSize) + } +} + +func TestLoadArgs_Flags(t *testing.T) { + args := []string{ + "--nats-addr", "nats://custom:4222", + "--addr", ":9090", + "--buffer-size", "50", + "--subject", "test.>", + } + cfg, err := LoadArgs(args) + if err != nil { + t.Fatalf("LoadArgs() error = %v", err) + } + + if cfg.NATSAddr != "nats://custom:4222" { + t.Errorf("NATSAddr = %q, want %q", cfg.NATSAddr, "nats://custom:4222") + } + if cfg.Addr != ":9090" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":9090") + } + if cfg.BufferSize != 50 { + t.Errorf("BufferSize = %d, want 50", cfg.BufferSize) + } + if cfg.Subject != "test.>" { + t.Errorf("Subject = %q, want %q", cfg.Subject, "test.>") + } +} + +func TestLoadArgs_EnvVars(t *testing.T) { + t.Setenv("NATS_ADDR", "nats://env:4222") + t.Setenv("ACTIVITY_FEED_ADDR", ":7070") + t.Setenv("ACTIVITY_FEED_BUFFER", "200") + t.Setenv("ACTIVITY_FEED_SUBJECT", "env.>") + + cfg, err := LoadArgs([]string{}) + if err != nil { + t.Fatalf("LoadArgs() error = %v", err) + } + + if cfg.NATSAddr != "nats://env:4222" { + t.Errorf("NATSAddr = %q, want %q", cfg.NATSAddr, "nats://env:4222") + } + if cfg.Addr != ":7070" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":7070") + } + if cfg.BufferSize != 200 { + t.Errorf("BufferSize = %d, want 200", cfg.BufferSize) + } + if cfg.Subject != "env.>" { + t.Errorf("Subject = %q, want %q", cfg.Subject, "env.>") + } +} diff --git a/go/plugins/nats-activity-feed/internal/feed/subscriber.go b/go/plugins/nats-activity-feed/internal/feed/subscriber.go new file mode 100644 index 000000000..c11a81ece --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/feed/subscriber.go @@ -0,0 +1,83 @@ +package feed + +import ( + "encoding/json" + "log" + "strings" + + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + "github.com/nats-io/nats.go" +) + +// Broadcaster is the interface for broadcasting feed events. +type Broadcaster interface { + Broadcast(event FeedEvent) +} + +// Subscriber connects to NATS and forwards parsed events to a Broadcaster. +type Subscriber struct { + conn *nats.Conn + sub *nats.Subscription + hub Broadcaster + subject string +} + +// NewSubscriber creates a NATS subscriber that parses messages and broadcasts FeedEvents. +func NewSubscriber(nc *nats.Conn, subject string, hub Broadcaster) (*Subscriber, error) { + s := &Subscriber{ + conn: nc, + hub: hub, + subject: subject, + } + + sub, err := nc.Subscribe(subject, s.handleMessage) + if err != nil { + return nil, err + } + s.sub = sub + return s, nil +} + +// Close drains the subscription. +func (s *Subscriber) Close() error { + if s.sub != nil { + return s.sub.Drain() + } + return nil +} + +func (s *Subscriber) handleMessage(msg *nats.Msg) { + agent, sessionID := parseSubject(msg.Subject) + + var event streaming.StreamEvent + if err := json.Unmarshal(msg.Data, &event); err != nil { + log.Printf("WARN: failed to parse StreamEvent from %s: %v", msg.Subject, err) + return + } + + s.hub.Broadcast(FeedEvent{ + Agent: agent, + SessionID: sessionID, + Subject: msg.Subject, + Type: string(event.Type), + Data: event.Data, + Timestamp: event.Timestamp, + }) +} + +// parseSubject extracts agent name and session ID from a NATS subject. +// Expected format: agent.{agentName}.{sessionID}.stream +// Returns (agent, sessionID). Unknown parts default to "unknown". +func parseSubject(subject string) (string, string) { + parts := strings.Split(subject, ".") + agent := "unknown" + sessionID := "unknown" + + if len(parts) >= 2 { + agent = parts[1] + } + if len(parts) >= 3 { + sessionID = parts[2] + } + return agent, sessionID +} diff --git a/go/plugins/nats-activity-feed/internal/feed/subscriber_test.go b/go/plugins/nats-activity-feed/internal/feed/subscriber_test.go new file mode 100644 index 000000000..1b27e6014 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/feed/subscriber_test.go @@ -0,0 +1,161 @@ +package feed + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/adk/pkg/streaming" + natsserver "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" +) + +func TestParseSubject(t *testing.T) { + tests := []struct { + name string + subject string + wantAgent string + wantSess string + }{ + {"full subject", "agent.myagent.sess123.stream", "myagent", "sess123"}, + {"no session", "agent.myagent", "myagent", "unknown"}, + {"no agent", "agent", "unknown", "unknown"}, + {"extra parts", "agent.a.b.c.d", "a", "b"}, + {"empty", "", "unknown", "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent, sess := parseSubject(tt.subject) + if agent != tt.wantAgent { + t.Errorf("agent = %q, want %q", agent, tt.wantAgent) + } + if sess != tt.wantSess { + t.Errorf("session = %q, want %q", sess, tt.wantSess) + } + }) + } +} + +// mockHub captures broadcast events for testing. +type mockHub struct { + events []FeedEvent + ch chan FeedEvent +} + +func newMockHub() *mockHub { + return &mockHub{ch: make(chan FeedEvent, 100)} +} + +func (m *mockHub) Broadcast(event FeedEvent) { + m.events = append(m.events, event) + m.ch <- event +} + +func startEmbeddedNATS(t *testing.T) *natsserver.Server { + t.Helper() + opts := &natsserver.Options{ + Host: "127.0.0.1", + Port: -1, // random port + } + ns, err := natsserver.NewServer(opts) + if err != nil { + t.Fatalf("failed to create NATS server: %v", err) + } + ns.Start() + if !ns.ReadyForConnections(5 * time.Second) { + t.Fatal("NATS server not ready") + } + return ns +} + +func TestSubscriber_Integration(t *testing.T) { + ns := startEmbeddedNATS(t) + defer ns.Shutdown() + + nc, err := nats.Connect(ns.ClientURL()) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer nc.Close() + + hub := newMockHub() + sub, err := NewSubscriber(nc, "agent.>", hub) + if err != nil { + t.Fatalf("NewSubscriber: %v", err) + } + defer sub.Close() + + // Publish a valid StreamEvent + evt := streaming.StreamEvent{ + Type: streaming.EventTypeToolStart, + Data: `{"name":"search"}`, + Timestamp: 1234567890, + } + data, _ := json.Marshal(evt) + if err := nc.Publish("agent.test-agent.session-1.stream", data); err != nil { + t.Fatalf("publish: %v", err) + } + nc.Flush() + + select { + case fe := <-hub.ch: + if fe.Agent != "test-agent" { + t.Errorf("Agent = %q, want %q", fe.Agent, "test-agent") + } + if fe.SessionID != "session-1" { + t.Errorf("SessionID = %q, want %q", fe.SessionID, "session-1") + } + if fe.Type != "tool_start" { + t.Errorf("Type = %q, want %q", fe.Type, "tool_start") + } + if fe.Data != `{"name":"search"}` { + t.Errorf("Data = %q, want %q", fe.Data, `{"name":"search"}`) + } + if fe.Timestamp != 1234567890 { + t.Errorf("Timestamp = %d, want 1234567890", fe.Timestamp) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for FeedEvent") + } +} + +func TestSubscriber_MalformedMessage(t *testing.T) { + ns := startEmbeddedNATS(t) + defer ns.Shutdown() + + nc, err := nats.Connect(ns.ClientURL()) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer nc.Close() + + hub := newMockHub() + sub, err := NewSubscriber(nc, "agent.>", hub) + if err != nil { + t.Fatalf("NewSubscriber: %v", err) + } + defer sub.Close() + + // Publish malformed data + if err := nc.Publish("agent.bad.sess.stream", []byte("not json")); err != nil { + t.Fatalf("publish: %v", err) + } + nc.Flush() + + // Publish a valid event after the malformed one + evt := streaming.StreamEvent{Type: streaming.EventTypeToken, Data: "hello", Timestamp: 999} + data, _ := json.Marshal(evt) + if err := nc.Publish("agent.good.sess.stream", data); err != nil { + t.Fatalf("publish: %v", err) + } + nc.Flush() + + select { + case fe := <-hub.ch: + if fe.Agent != "good" { + t.Errorf("Expected good agent event, got %q", fe.Agent) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out — malformed message may have blocked subscriber") + } +} diff --git a/go/plugins/nats-activity-feed/internal/feed/types.go b/go/plugins/nats-activity-feed/internal/feed/types.go new file mode 100644 index 000000000..f04a315ca --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/feed/types.go @@ -0,0 +1,11 @@ +package feed + +// FeedEvent wraps a StreamEvent with subject metadata for the UI. +type FeedEvent struct { + Agent string `json:"agent"` + SessionID string `json:"sessionId"` + Subject string `json:"subject"` + Type string `json:"type"` + Data string `json:"data"` + Timestamp int64 `json:"timestamp"` +} diff --git a/go/plugins/nats-activity-feed/internal/sse/hub.go b/go/plugins/nats-activity-feed/internal/sse/hub.go new file mode 100644 index 000000000..f42280ba4 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/sse/hub.go @@ -0,0 +1,137 @@ +package sse + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/kagent-dev/kagent/go/plugins/nats-activity-feed/internal/feed" +) + +const subBufferSize = 16 + +// Hub manages SSE subscriber connections and broadcasts FeedEvents. +// It maintains a ring buffer of recent events for new subscribers. +type Hub struct { + mu sync.RWMutex + subs map[chan feed.FeedEvent]struct{} + ring []feed.FeedEvent + ringSize int + ringOffset int + ringCount int +} + +// NewHub creates a Hub with the given ring buffer capacity. +func NewHub(bufferSize int) *Hub { + if bufferSize <= 0 { + bufferSize = 100 + } + return &Hub{ + subs: make(map[chan feed.FeedEvent]struct{}), + ring: make([]feed.FeedEvent, bufferSize), + ringSize: bufferSize, + } +} + +// Subscribe registers a new subscriber and returns a buffered channel. +func (h *Hub) Subscribe() chan feed.FeedEvent { + ch := make(chan feed.FeedEvent, subBufferSize) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +// Unsubscribe removes the given subscriber channel. +func (h *Hub) Unsubscribe(ch chan feed.FeedEvent) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() +} + +// Broadcast adds event to the ring buffer and fans out to all subscribers. +func (h *Hub) Broadcast(event feed.FeedEvent) { + h.mu.Lock() + // Add to ring buffer + h.ring[h.ringOffset] = event + h.ringOffset = (h.ringOffset + 1) % h.ringSize + if h.ringCount < h.ringSize { + h.ringCount++ + } + + clients := make([]chan feed.FeedEvent, 0, len(h.subs)) + for ch := range h.subs { + clients = append(clients, ch) + } + h.mu.Unlock() + + for _, ch := range clients { + select { + case ch <- event: + default: // drop for slow subscribers + } + } +} + +// snapshot returns the ring buffer contents in chronological order. +func (h *Hub) snapshot() []feed.FeedEvent { + h.mu.RLock() + defer h.mu.RUnlock() + + if h.ringCount == 0 { + return nil + } + + result := make([]feed.FeedEvent, 0, h.ringCount) + start := 0 + if h.ringCount == h.ringSize { + start = h.ringOffset // oldest element + } + for i := 0; i < h.ringCount; i++ { + idx := (start + i) % h.ringSize + result = append(result, h.ring[idx]) + } + return result +} + +// ServeSSE handles the /events SSE endpoint. +func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + ch := h.Subscribe() + defer h.Unsubscribe(ch) + + // Send ring buffer contents as initial burst + events := h.snapshot() + for _, event := range events { + eventJSON, err := json.Marshal(event) + if err != nil { + continue + } + fmt.Fprintf(w, "event: activity\ndata: %s\n\n", eventJSON) + } + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case event := <-ch: + eventJSON, err := json.Marshal(event) + if err != nil { + continue + } + fmt.Fprintf(w, "event: activity\ndata: %s\n\n", eventJSON) + flusher.Flush() + } + } +} diff --git a/go/plugins/nats-activity-feed/internal/sse/hub_test.go b/go/plugins/nats-activity-feed/internal/sse/hub_test.go new file mode 100644 index 000000000..7401d406e --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/sse/hub_test.go @@ -0,0 +1,126 @@ +package sse + +import ( + "bufio" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/nats-activity-feed/internal/feed" +) + +func TestHub_Broadcast_Received(t *testing.T) { + h := NewHub(10) + ch := h.Subscribe() + defer h.Unsubscribe(ch) + + event := feed.FeedEvent{Agent: "a1", Type: "token", Data: "hello"} + h.Broadcast(event) + + select { + case got := <-ch: + if got.Agent != "a1" { + t.Errorf("Agent = %q, want %q", got.Agent, "a1") + } + case <-time.After(time.Second): + t.Fatal("timed out") + } +} + +func TestHub_Broadcast_NonBlocking(t *testing.T) { + h := NewHub(10) + slow := h.Subscribe() + + // Fill slow subscriber's buffer + for i := 0; i < subBufferSize; i++ { + slow <- feed.FeedEvent{Data: "fill"} + } + + done := make(chan struct{}) + go func() { + h.Broadcast(feed.FeedEvent{Data: "new"}) + close(done) + }() + + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("Broadcast blocked on full subscriber") + } +} + +func TestHub_RingBuffer(t *testing.T) { + h := NewHub(3) + + h.Broadcast(feed.FeedEvent{Data: "1"}) + h.Broadcast(feed.FeedEvent{Data: "2"}) + h.Broadcast(feed.FeedEvent{Data: "3"}) + h.Broadcast(feed.FeedEvent{Data: "4"}) // overwrites "1" + + snap := h.snapshot() + if len(snap) != 3 { + t.Fatalf("snapshot len = %d, want 3", len(snap)) + } + if snap[0].Data != "2" { + t.Errorf("snap[0].Data = %q, want %q", snap[0].Data, "2") + } + if snap[2].Data != "4" { + t.Errorf("snap[2].Data = %q, want %q", snap[2].Data, "4") + } +} + +func TestHub_ServeSSE_InitialBurst(t *testing.T) { + h := NewHub(10) + h.Broadcast(feed.FeedEvent{Agent: "a1", Type: "token", Data: "first"}) + h.Broadcast(feed.FeedEvent{Agent: "a2", Type: "error", Data: "second"}) + + srv := httptest.NewServer(http.HandlerFunc(h.ServeSSE)) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + var events []feed.FeedEvent + + // Read initial burst events (within timeout) + done := time.After(2 * time.Second) + for { + select { + case <-done: + goto check + default: + } + + if !scanner.Scan() { + break + } + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + var fe feed.FeedEvent + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &fe); err == nil { + events = append(events, fe) + if len(events) >= 2 { + goto check + } + } + } + } + +check: + if len(events) < 2 { + t.Fatalf("got %d events, want at least 2", len(events)) + } + if events[0].Data != "first" { + t.Errorf("events[0].Data = %q, want %q", events[0].Data, "first") + } + if events[1].Data != "second" { + t.Errorf("events[1].Data = %q, want %q", events[1].Data, "second") + } +} diff --git a/go/plugins/nats-activity-feed/internal/ui/embed.go b/go/plugins/nats-activity-feed/internal/ui/embed.go new file mode 100644 index 000000000..8a41bef90 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/ui/embed.go @@ -0,0 +1,17 @@ +package ui + +import ( + _ "embed" + "net/http" +) + +//go:embed index.html +var indexHTML []byte + +// Handler returns an http.Handler that serves the embedded SPA. +func Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) //nolint:errcheck + }) +} diff --git a/go/plugins/nats-activity-feed/internal/ui/embed_test.go b/go/plugins/nats-activity-feed/internal/ui/embed_test.go new file mode 100644 index 000000000..99082c7b2 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/ui/embed_test.go @@ -0,0 +1,28 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandler_ServesHTML(t *testing.T) { + if len(indexHTML) == 0 { + t.Fatal("embedded index.html is empty") + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + ct := w.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "text/html; charset=utf-8") + } + if w.Body.Len() == 0 { + t.Error("response body is empty") + } +} diff --git a/go/plugins/nats-activity-feed/internal/ui/index.html b/go/plugins/nats-activity-feed/internal/ui/index.html new file mode 100644 index 000000000..845d41ca4 --- /dev/null +++ b/go/plugins/nats-activity-feed/internal/ui/index.html @@ -0,0 +1,330 @@ + + + + + +Activity Feed — kagent + + + + +
+

  Activity Feed

+
+ + + + + + + + + 0 events +
+
+ +
+
Waiting for activity...
+
+ + + + diff --git a/go/plugins/nats-activity-feed/main.go b/go/plugins/nats-activity-feed/main.go new file mode 100644 index 000000000..7346cb382 --- /dev/null +++ b/go/plugins/nats-activity-feed/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/kagent-dev/kagent/go/plugins/nats-activity-feed/internal/config" + "github.com/kagent-dev/kagent/go/plugins/nats-activity-feed/internal/feed" + "github.com/kagent-dev/kagent/go/plugins/nats-activity-feed/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/nats-activity-feed/internal/ui" + "github.com/nats-io/nats.go" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + // Connect to NATS with auto-reconnect + nc, err := nats.Connect(cfg.NATSAddr, + nats.MaxReconnects(-1), + nats.DisconnectErrHandler(func(_ *nats.Conn, err error) { + log.Printf("NATS disconnected: %v", err) + }), + nats.ReconnectHandler(func(_ *nats.Conn) { + log.Println("NATS reconnected") + }), + ) + if err != nil { + log.Fatalf("nats connect: %v", err) + } + defer nc.Close() + + hub := sse.NewHub(cfg.BufferSize) + + sub, err := feed.NewSubscriber(nc, cfg.Subject, hub) + if err != nil { + log.Fatalf("subscriber: %v", err) + } + defer sub.Close() + + mux := http.NewServeMux() + mux.HandleFunc("/events", hub.ServeSSE) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) //nolint:errcheck + }) + mux.Handle("/", ui.Handler()) + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: mux, + } + + go func() { + <-ctx.Done() + srv.Close() + }() + + log.Printf("nats-activity-feed listening on %s (NATS: %s, subject: %s, buffer: %d)", + cfg.Addr, cfg.NATSAddr, cfg.Subject, cfg.BufferSize) + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http: %v", err) + } +} diff --git a/go/plugins/temporal-mcp/go.mod b/go/plugins/temporal-mcp/go.mod new file mode 100644 index 000000000..f3a65d423 --- /dev/null +++ b/go/plugins/temporal-mcp/go.mod @@ -0,0 +1,39 @@ +module github.com/kagent-dev/kagent/go/plugins/temporal-mcp + +go 1.25.7 + +require ( + github.com/modelcontextprotocol/go-sdk v1.4.0 + go.temporal.io/api v1.62.2 + go.temporal.io/sdk v1.40.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/nexus-rpc/sdk-go v0.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go/plugins/temporal-mcp/go.sum b/go/plugins/temporal-mcp/go.sum new file mode 100644 index 000000000..bfd425fb3 --- /dev/null +++ b/go/plugins/temporal-mcp/go.sum @@ -0,0 +1,113 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/nexus-rpc/sdk-go v0.5.1 h1:UFYYfoHlQc+Pn9gQpmn9QE7xluewAn2AO1OSkAh7YFU= +github.com/nexus-rpc/sdk-go v0.5.1/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.temporal.io/api v1.62.2 h1:jFhIzlqNyJsJZTiCRQmTIMv6OTQ5BZ57z8gbgLGMaoo= +go.temporal.io/api v1.62.2/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +go.temporal.io/sdk v1.40.0 h1:n9JN3ezVpWBxLzz5xViCo0sKxp7kVVhr1Su0bcMRNNs= +go.temporal.io/sdk v1.40.0/go.mod h1:tauxVfN174F0bdEs27+i0h8UPD7xBb6Py2SPHo7f1C0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0= +google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/plugins/temporal-mcp/internal/api/handlers.go b/go/plugins/temporal-mcp/internal/api/handlers.go new file mode 100644 index 000000000..0a5e8234f --- /dev/null +++ b/go/plugins/temporal-mcp/internal/api/handlers.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" +) + +// writeJSON encodes v as JSON with the given HTTP status code. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) //nolint:errcheck +} + +// writeError sends a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// WorkflowsHandler handles GET /api/workflows (list). +func WorkflowsHandler(tc temporal.WorkflowClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + filter := temporal.WorkflowFilter{ + Status: r.URL.Query().Get("status"), + AgentName: r.URL.Query().Get("agent"), + } + if ps := r.URL.Query().Get("page_size"); ps != "" { + if n, err := strconv.Atoi(ps); err == nil && n > 0 { + filter.PageSize = n + } + } + + workflows, err := tc.ListWorkflows(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"data": workflows}) + } +} + +// WorkflowHandler handles /api/workflows/{id}, /api/workflows/{id}/cancel, /api/workflows/{id}/signal. +func WorkflowHandler(tc temporal.WorkflowClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Extract workflow ID and suffix from path + path := strings.TrimPrefix(r.URL.Path, "/api/workflows/") + if path == "" || path == r.URL.Path { + http.NotFound(w, r) + return + } + + var workflowID, suffix string + if idx := strings.Index(path, "/"); idx >= 0 { + workflowID = path[:idx] + suffix = path[idx:] + } else { + workflowID = path + } + + switch { + case suffix == "/cancel" && r.Method == http.MethodPost: + if err := tc.CancelWorkflow(r.Context(), workflowID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"canceled": true}) + + case suffix == "/signal" && r.Method == http.MethodPost: + var body struct { + SignalName string `json:"signal_name"` + Data interface{} `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.SignalName == "" { + writeError(w, http.StatusBadRequest, "signal_name is required") + return + } + if err := tc.SignalWorkflow(r.Context(), workflowID, body.SignalName, body.Data); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"signaled": true}) + + case suffix == "" && r.Method == http.MethodGet: + detail, err := tc.GetWorkflow(r.Context(), workflowID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"data": detail}) + + default: + http.NotFound(w, r) + } + } +} diff --git a/go/plugins/temporal-mcp/internal/api/handlers_test.go b/go/plugins/temporal-mcp/internal/api/handlers_test.go new file mode 100644 index 000000000..1fa3c4065 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/api/handlers_test.go @@ -0,0 +1,211 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + temporalapi "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/api" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" +) + +type mockTC struct { + workflows []*temporal.WorkflowSummary + detail *temporal.WorkflowDetail + listErr error + getErr error + cancelErr error + signalErr error + canceled []string +} + +func (m *mockTC) ListWorkflows(_ context.Context, _ temporal.WorkflowFilter) ([]*temporal.WorkflowSummary, error) { + if m.listErr != nil { + return nil, m.listErr + } + if m.workflows == nil { + return []*temporal.WorkflowSummary{}, nil + } + return m.workflows, nil +} + +func (m *mockTC) GetWorkflow(_ context.Context, id string) (*temporal.WorkflowDetail, error) { + if m.getErr != nil { + return nil, m.getErr + } + if m.detail != nil { + return m.detail, nil + } + return nil, fmt.Errorf("not found: %s", id) +} + +func (m *mockTC) CancelWorkflow(_ context.Context, id string) error { + if m.cancelErr != nil { + return m.cancelErr + } + m.canceled = append(m.canceled, id) + return nil +} + +func (m *mockTC) SignalWorkflow(_ context.Context, _, _ string, _ interface{}) error { + return m.signalErr +} + +func newTestServer(t *testing.T, tc *mockTC) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/workflows", temporalapi.WorkflowsHandler(tc)) + mux.HandleFunc("/api/workflows/", temporalapi.WorkflowHandler(tc)) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestREST_ListWorkflows(t *testing.T) { + now := time.Now() + tc := &mockTC{ + workflows: []*temporal.WorkflowSummary{ + {WorkflowID: "wf-1", Status: "Running", StartTime: now}, + {WorkflowID: "wf-2", Status: "Completed", StartTime: now}, + }, + } + srv := newTestServer(t, tc) + + resp, err := http.Get(srv.URL + "/api/workflows") + if err != nil { + t.Fatalf("GET /api/workflows: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result struct { + Data []*temporal.WorkflowSummary `json:"data"` + } + json.NewDecoder(resp.Body).Decode(&result) //nolint:errcheck + if len(result.Data) != 2 { + t.Errorf("expected 2 workflows, got %d", len(result.Data)) + } +} + +func TestREST_ListWorkflows_Error(t *testing.T) { + tc := &mockTC{listErr: fmt.Errorf("connection refused")} + srv := newTestServer(t, tc) + + resp, err := http.Get(srv.URL + "/api/workflows") + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", resp.StatusCode) + } +} + +func TestREST_GetWorkflow(t *testing.T) { + now := time.Now() + tc := &mockTC{ + detail: &temporal.WorkflowDetail{ + WorkflowSummary: temporal.WorkflowSummary{ + WorkflowID: "wf-1", + Status: "Running", + StartTime: now, + }, + Activities: []temporal.ActivityInfo{}, + }, + } + srv := newTestServer(t, tc) + + resp, err := http.Get(srv.URL + "/api/workflows/wf-1") + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result struct { + Data temporal.WorkflowDetail `json:"data"` + } + json.NewDecoder(resp.Body).Decode(&result) //nolint:errcheck + if result.Data.WorkflowID != "wf-1" { + t.Errorf("expected wf-1, got %q", result.Data.WorkflowID) + } +} + +func TestREST_CancelWorkflow(t *testing.T) { + tc := &mockTC{} + srv := newTestServer(t, tc) + + resp, err := http.Post(srv.URL+"/api/workflows/wf-1/cancel", "", nil) + if err != nil { + t.Fatalf("POST cancel: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + if len(tc.canceled) != 1 || tc.canceled[0] != "wf-1" { + t.Errorf("expected cancel of wf-1, got %v", tc.canceled) + } +} + +func TestREST_SignalWorkflow(t *testing.T) { + tc := &mockTC{} + srv := newTestServer(t, tc) + + body := `{"signal_name":"approve","data":{"ok":true}}` + resp, err := http.Post(srv.URL+"/api/workflows/wf-1/signal", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST signal: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestREST_SignalWorkflow_MissingName(t *testing.T) { + tc := &mockTC{} + srv := newTestServer(t, tc) + + body := `{"data":"hello"}` + resp, err := http.Post(srv.URL+"/api/workflows/wf-1/signal", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST signal: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } +} + +func TestREST_MethodNotAllowed(t *testing.T) { + tc := &mockTC{} + srv := newTestServer(t, tc) + + req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/workflows", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("DELETE: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } +} diff --git a/go/plugins/temporal-mcp/internal/config/config.go b/go/plugins/temporal-mcp/internal/config/config.go new file mode 100644 index 000000000..5d2165a1a --- /dev/null +++ b/go/plugins/temporal-mcp/internal/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "flag" + "os" + "time" +) + +// Config holds all runtime settings for the temporal-mcp server. +type Config struct { + Addr string // --addr / TEMPORAL_ADDR, default ":8080" + Transport string // --transport / TEMPORAL_TRANSPORT, "http" | "stdio" + TemporalHostPort string // --temporal-host-port / TEMPORAL_HOST_PORT + TemporalNamespace string // --temporal-namespace / TEMPORAL_NAMESPACE + PollInterval time.Duration // --poll-interval / TEMPORAL_POLL_INTERVAL + LogLevel string // --log-level / TEMPORAL_LOG_LEVEL + WebUIURL string // --webui-url / TEMPORAL_WEBUI_URL, URL of official Temporal Web UI + ProxyPrefix string // --proxy-prefix / TEMPORAL_PROXY_PREFIX, external path prefix (e.g. "/_p/temporal") +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// Load parses CLI flags (os.Args[1:]) with TEMPORAL_* environment variable fallback. +func Load() (*Config, error) { + return LoadArgs(os.Args[1:]) +} + +// LoadArgs parses the given args with TEMPORAL_* environment variable fallback. +func LoadArgs(args []string) (*Config, error) { + fs := flag.NewFlagSet("temporal-mcp", flag.ContinueOnError) + + addr := fs.String("addr", envOrDefault("TEMPORAL_ADDR", ":8080"), "listen address") + transport := fs.String("transport", envOrDefault("TEMPORAL_TRANSPORT", "http"), "transport mode: http or stdio") + hostPort := fs.String("temporal-host-port", envOrDefault("TEMPORAL_HOST_PORT", "temporal-server:7233"), "Temporal gRPC address") + namespace := fs.String("temporal-namespace", envOrDefault("TEMPORAL_NAMESPACE", "kagent"), "Temporal namespace") + pollIntervalStr := fs.String("poll-interval", envOrDefault("TEMPORAL_POLL_INTERVAL", "5s"), "SSE poll interval") + logLevel := fs.String("log-level", envOrDefault("TEMPORAL_LOG_LEVEL", "info"), "log level: debug, info, warn, error") + webuiURL := fs.String("webui-url", envOrDefault("TEMPORAL_WEBUI_URL", ""), "URL of official Temporal Web UI (optional)") + proxyPrefix := fs.String("proxy-prefix", envOrDefault("TEMPORAL_PROXY_PREFIX", ""), "external path prefix for reverse proxy path rewriting (e.g. /_p/temporal)") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + pollInterval, err := time.ParseDuration(*pollIntervalStr) + if err != nil { + pollInterval = 5 * time.Second + } + + return &Config{ + Addr: *addr, + Transport: *transport, + TemporalHostPort: *hostPort, + TemporalNamespace: *namespace, + PollInterval: pollInterval, + LogLevel: *logLevel, + WebUIURL: *webuiURL, + ProxyPrefix: *proxyPrefix, + }, nil +} diff --git a/go/plugins/temporal-mcp/internal/config/config_test.go b/go/plugins/temporal-mcp/internal/config/config_test.go new file mode 100644 index 000000000..1ee177419 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/config/config_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "testing" + "time" +) + +func TestLoadArgs_Defaults(t *testing.T) { + cfg, err := LoadArgs(nil) + if err != nil { + t.Fatalf("LoadArgs: %v", err) + } + if cfg.Addr != ":8080" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":8080") + } + if cfg.Transport != "http" { + t.Errorf("Transport = %q, want %q", cfg.Transport, "http") + } + if cfg.TemporalHostPort != "temporal-server:7233" { + t.Errorf("TemporalHostPort = %q, want %q", cfg.TemporalHostPort, "temporal-server:7233") + } + if cfg.TemporalNamespace != "kagent" { + t.Errorf("TemporalNamespace = %q, want %q", cfg.TemporalNamespace, "kagent") + } + if cfg.PollInterval != 5*time.Second { + t.Errorf("PollInterval = %v, want %v", cfg.PollInterval, 5*time.Second) + } + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } +} + +func TestLoadArgs_FlagOverrides(t *testing.T) { + args := []string{ + "--addr", ":9090", + "--transport", "stdio", + "--temporal-host-port", "localhost:7233", + "--temporal-namespace", "test-ns", + "--poll-interval", "10s", + "--log-level", "debug", + } + cfg, err := LoadArgs(args) + if err != nil { + t.Fatalf("LoadArgs: %v", err) + } + if cfg.Addr != ":9090" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":9090") + } + if cfg.Transport != "stdio" { + t.Errorf("Transport = %q, want %q", cfg.Transport, "stdio") + } + if cfg.TemporalHostPort != "localhost:7233" { + t.Errorf("TemporalHostPort = %q, want %q", cfg.TemporalHostPort, "localhost:7233") + } + if cfg.TemporalNamespace != "test-ns" { + t.Errorf("TemporalNamespace = %q, want %q", cfg.TemporalNamespace, "test-ns") + } + if cfg.PollInterval != 10*time.Second { + t.Errorf("PollInterval = %v, want %v", cfg.PollInterval, 10*time.Second) + } + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "debug") + } +} + +func TestLoadArgs_EnvOverride(t *testing.T) { + t.Setenv("TEMPORAL_HOST_PORT", "env-host:7233") + t.Setenv("TEMPORAL_NAMESPACE", "env-ns") + + cfg, err := LoadArgs(nil) + if err != nil { + t.Fatalf("LoadArgs: %v", err) + } + if cfg.TemporalHostPort != "env-host:7233" { + t.Errorf("TemporalHostPort = %q, want %q", cfg.TemporalHostPort, "env-host:7233") + } + if cfg.TemporalNamespace != "env-ns" { + t.Errorf("TemporalNamespace = %q, want %q", cfg.TemporalNamespace, "env-ns") + } +} + +func TestLoadArgs_InvalidPollInterval(t *testing.T) { + args := []string{"--poll-interval", "not-a-duration"} + cfg, err := LoadArgs(args) + if err != nil { + t.Fatalf("LoadArgs: %v", err) + } + if cfg.PollInterval != 5*time.Second { + t.Errorf("PollInterval = %v, want fallback %v", cfg.PollInterval, 5*time.Second) + } +} diff --git a/go/plugins/temporal-mcp/internal/mcp/tools.go b/go/plugins/temporal-mcp/internal/mcp/tools.go new file mode 100644 index 000000000..ccea3ddb3 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/mcp/tools.go @@ -0,0 +1,154 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NewServer creates an MCP server with the 4 Temporal workflow tools registered. +func NewServer(tc temporal.WorkflowClient) *mcpsdk.Server { + server := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "temporal-workflows", + Version: "v1.0.0", + }, nil) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "list_workflows", + Description: "List Temporal workflow executions, optionally filtered by status or agent name.", + }, handleListWorkflows(tc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "get_workflow", + Description: "Get detailed information about a specific workflow execution including activity history.", + }, handleGetWorkflow(tc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "cancel_workflow", + Description: "Cancel a running workflow execution.", + }, handleCancelWorkflow(tc)) + + mcpsdk.AddTool(server, &mcpsdk.Tool{ + Name: "signal_workflow", + Description: "Send a signal to a running workflow execution.", + }, handleSignalWorkflow(tc)) + + return server +} + +// textResult wraps a value as a JSON text content result. +func textResult(v interface{}) (*mcpsdk.CallToolResult, interface{}, error) { + data, err := json.Marshal(v) + if err != nil { + return errorResult(fmt.Sprintf("failed to marshal result: %v", err)), nil, nil + } + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: string(data)}, + }, + }, nil, nil +} + +// errorResult returns an MCP error result with isError=true. +func errorResult(msg string) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: msg}, + }, + } +} + +// --- Tool input types --- + +type listWorkflowsInput struct { + Status string `json:"status,omitempty"` + AgentName string `json:"agent_name,omitempty"` + PageSize int `json:"page_size,omitempty"` +} + +type getWorkflowInput struct { + WorkflowID string `json:"workflow_id"` +} + +type cancelWorkflowInput struct { + WorkflowID string `json:"workflow_id"` +} + +type signalWorkflowInput struct { + WorkflowID string `json:"workflow_id"` + SignalName string `json:"signal_name"` + Data string `json:"data,omitempty"` +} + +// --- Tool handlers --- + +func handleListWorkflows(tc temporal.WorkflowClient) func(context.Context, *mcpsdk.CallToolRequest, listWorkflowsInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input listWorkflowsInput) (*mcpsdk.CallToolResult, interface{}, error) { + pageSize := input.PageSize + if pageSize <= 0 { + pageSize = 50 + } + filter := temporal.WorkflowFilter{ + Status: input.Status, + AgentName: input.AgentName, + PageSize: pageSize, + } + workflows, err := tc.ListWorkflows(ctx, filter) + if err != nil { + return errorResult(fmt.Sprintf("list_workflows failed: %v", err)), nil, nil + } + return textResult(workflows) + } +} + +func handleGetWorkflow(tc temporal.WorkflowClient) func(context.Context, *mcpsdk.CallToolRequest, getWorkflowInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input getWorkflowInput) (*mcpsdk.CallToolResult, interface{}, error) { + if input.WorkflowID == "" { + return errorResult("workflow_id is required"), nil, nil + } + detail, err := tc.GetWorkflow(ctx, input.WorkflowID) + if err != nil { + return errorResult(fmt.Sprintf("get_workflow failed: %v", err)), nil, nil + } + return textResult(detail) + } +} + +func handleCancelWorkflow(tc temporal.WorkflowClient) func(context.Context, *mcpsdk.CallToolRequest, cancelWorkflowInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input cancelWorkflowInput) (*mcpsdk.CallToolResult, interface{}, error) { + if input.WorkflowID == "" { + return errorResult("workflow_id is required"), nil, nil + } + if err := tc.CancelWorkflow(ctx, input.WorkflowID); err != nil { + return errorResult(fmt.Sprintf("cancel_workflow failed: %v", err)), nil, nil + } + return textResult(map[string]interface{}{"canceled": true, "workflow_id": input.WorkflowID}) + } +} + +func handleSignalWorkflow(tc temporal.WorkflowClient) func(context.Context, *mcpsdk.CallToolRequest, signalWorkflowInput) (*mcpsdk.CallToolResult, interface{}, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, input signalWorkflowInput) (*mcpsdk.CallToolResult, interface{}, error) { + if input.WorkflowID == "" { + return errorResult("workflow_id is required"), nil, nil + } + if input.SignalName == "" { + return errorResult("signal_name is required"), nil, nil + } + + var data interface{} + if input.Data != "" { + if err := json.Unmarshal([]byte(input.Data), &data); err != nil { + data = input.Data // treat as plain string if not valid JSON + } + } + + if err := tc.SignalWorkflow(ctx, input.WorkflowID, input.SignalName, data); err != nil { + return errorResult(fmt.Sprintf("signal_workflow failed: %v", err)), nil, nil + } + return textResult(map[string]interface{}{"signaled": true, "workflow_id": input.WorkflowID, "signal_name": input.SignalName}) + } +} diff --git a/go/plugins/temporal-mcp/internal/mcp/tools_test.go b/go/plugins/temporal-mcp/internal/mcp/tools_test.go new file mode 100644 index 000000000..9eaa78457 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/mcp/tools_test.go @@ -0,0 +1,262 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" + temporalmcp "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/mcp" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// mockTC is a test double implementing temporal.WorkflowClient. +type mockTC struct { + workflows []*temporal.WorkflowSummary + detail *temporal.WorkflowDetail + listErr error + getErr error + cancelErr error + signalErr error + canceled []string + signaled []struct{ id, name string } +} + +func (m *mockTC) ListWorkflows(_ context.Context, _ temporal.WorkflowFilter) ([]*temporal.WorkflowSummary, error) { + if m.listErr != nil { + return nil, m.listErr + } + if m.workflows == nil { + return []*temporal.WorkflowSummary{}, nil + } + return m.workflows, nil +} + +func (m *mockTC) GetWorkflow(_ context.Context, id string) (*temporal.WorkflowDetail, error) { + if m.getErr != nil { + return nil, m.getErr + } + if m.detail != nil { + return m.detail, nil + } + return nil, fmt.Errorf("not found: %s", id) +} + +func (m *mockTC) CancelWorkflow(_ context.Context, id string) error { + if m.cancelErr != nil { + return m.cancelErr + } + m.canceled = append(m.canceled, id) + return nil +} + +func (m *mockTC) SignalWorkflow(_ context.Context, id, name string, _ interface{}) error { + if m.signalErr != nil { + return m.signalErr + } + m.signaled = append(m.signaled, struct{ id, name string }{id, name}) + return nil +} + +func setupTest(t *testing.T, tc temporal.WorkflowClient) (*mcpsdk.ClientSession, func()) { + t.Helper() + + server := temporalmcp.NewServer(tc) + + ctx := context.Background() + st, ct := mcpsdk.NewInMemoryTransports() + + _, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server.Connect: %v", err) + } + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("client.Connect: %v", err) + } + + return cs, func() { cs.Close() } +} + +func callTool(t *testing.T, cs *mcpsdk.ClientSession, name string, args map[string]interface{}) *mcpsdk.CallToolResult { + t.Helper() + result, err := cs.CallTool(context.Background(), &mcpsdk.CallToolParams{ + Name: name, + Arguments: args, + }) + if err != nil { + t.Fatalf("CallTool(%s): %v", name, err) + } + return result +} + +func extractText(t *testing.T, result *mcpsdk.CallToolResult) string { + t.Helper() + if len(result.Content) == 0 { + t.Fatal("result has no content") + } + tc, ok := result.Content[0].(*mcpsdk.TextContent) + if !ok { + t.Fatalf("content[0] is not *TextContent") + } + return tc.Text +} + +func TestMCPTool_ListWorkflows(t *testing.T) { + now := time.Now() + tc := &mockTC{ + workflows: []*temporal.WorkflowSummary{ + {WorkflowID: "agent-k8s-agent-abc", AgentName: "k8s-agent", Status: "Running", StartTime: now}, + {WorkflowID: "agent-k8s-agent-def", AgentName: "k8s-agent", Status: "Completed", StartTime: now}, + }, + } + cs, cleanup := setupTest(t, tc) + defer cleanup() + + result := callTool(t, cs, "list_workflows", map[string]interface{}{}) + if result.IsError { + t.Fatalf("list_workflows returned error: %s", extractText(t, result)) + } + + var workflows []*temporal.WorkflowSummary + if err := json.Unmarshal([]byte(extractText(t, result)), &workflows); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(workflows) != 2 { + t.Errorf("expected 2 workflows, got %d", len(workflows)) + } +} + +func TestMCPTool_ListWorkflows_Error(t *testing.T) { + tc := &mockTC{listErr: fmt.Errorf("connection refused")} + cs, cleanup := setupTest(t, tc) + defer cleanup() + + result := callTool(t, cs, "list_workflows", map[string]interface{}{}) + if !result.IsError { + t.Error("expected isError for connection failure") + } +} + +func TestMCPTool_GetWorkflow(t *testing.T) { + now := time.Now() + tc := &mockTC{ + detail: &temporal.WorkflowDetail{ + WorkflowSummary: temporal.WorkflowSummary{ + WorkflowID: "agent-k8s-agent-abc", + AgentName: "k8s-agent", + Status: "Running", + StartTime: now, + }, + Activities: []temporal.ActivityInfo{ + {Name: "LLMActivity", Status: "Completed", StartTime: now, Duration: "1.5s"}, + }, + }, + } + cs, cleanup := setupTest(t, tc) + defer cleanup() + + result := callTool(t, cs, "get_workflow", map[string]interface{}{ + "workflow_id": "agent-k8s-agent-abc", + }) + if result.IsError { + t.Fatalf("get_workflow returned error: %s", extractText(t, result)) + } + + var detail temporal.WorkflowDetail + if err := json.Unmarshal([]byte(extractText(t, result)), &detail); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if detail.WorkflowID != "agent-k8s-agent-abc" { + t.Errorf("expected workflow ID agent-k8s-agent-abc, got %q", detail.WorkflowID) + } + if len(detail.Activities) != 1 { + t.Errorf("expected 1 activity, got %d", len(detail.Activities)) + } +} + +func TestMCPTool_GetWorkflow_MissingID(t *testing.T) { + tc := &mockTC{} + cs, cleanup := setupTest(t, tc) + defer cleanup() + + // MCP SDK validates required fields — missing workflow_id returns a protocol-level error + _, err := cs.CallTool(context.Background(), &mcpsdk.CallToolParams{ + Name: "get_workflow", + Arguments: map[string]interface{}{}, + }) + if err == nil { + t.Error("expected error for missing workflow_id") + } +} + +func TestMCPTool_CancelWorkflow(t *testing.T) { + tc := &mockTC{} + cs, cleanup := setupTest(t, tc) + defer cleanup() + + result := callTool(t, cs, "cancel_workflow", map[string]interface{}{ + "workflow_id": "agent-k8s-agent-abc", + }) + if result.IsError { + t.Fatalf("cancel_workflow returned error: %s", extractText(t, result)) + } + + if len(tc.canceled) != 1 || tc.canceled[0] != "agent-k8s-agent-abc" { + t.Errorf("expected cancel call with 'agent-k8s-agent-abc', got %v", tc.canceled) + } +} + +func TestMCPTool_CancelWorkflow_Error(t *testing.T) { + tc := &mockTC{cancelErr: fmt.Errorf("workflow already completed")} + cs, cleanup := setupTest(t, tc) + defer cleanup() + + result := callTool(t, cs, "cancel_workflow", map[string]interface{}{ + "workflow_id": "wf-1", + }) + if !result.IsError { + t.Error("expected error for cancel failure") + } +} + +func TestMCPTool_SignalWorkflow(t *testing.T) { + tc := &mockTC{} + cs, cleanup := setupTest(t, tc) + defer cleanup() + + result := callTool(t, cs, "signal_workflow", map[string]interface{}{ + "workflow_id": "agent-k8s-agent-abc", + "signal_name": "approve", + "data": `{"approved": true}`, + }) + if result.IsError { + t.Fatalf("signal_workflow returned error: %s", extractText(t, result)) + } + + if len(tc.signaled) != 1 { + t.Fatalf("expected 1 signal call, got %d", len(tc.signaled)) + } + if tc.signaled[0].name != "approve" { + t.Errorf("expected signal name 'approve', got %q", tc.signaled[0].name) + } +} + +func TestMCPTool_SignalWorkflow_MissingName(t *testing.T) { + tc := &mockTC{} + cs, cleanup := setupTest(t, tc) + defer cleanup() + + // MCP SDK validates required fields — missing signal_name returns a protocol-level error + _, err := cs.CallTool(context.Background(), &mcpsdk.CallToolParams{ + Name: "signal_workflow", + Arguments: map[string]interface{}{"workflow_id": "wf-1"}, + }) + if err == nil { + t.Error("expected error for missing signal_name") + } +} diff --git a/go/plugins/temporal-mcp/internal/sse/hub.go b/go/plugins/temporal-mcp/internal/sse/hub.go new file mode 100644 index 000000000..8312d1ff7 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/sse/hub.go @@ -0,0 +1,187 @@ +package sse + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" +) + +const subBufferSize = 16 + +// Event represents an SSE event sent to clients. +type Event struct { + Type string `json:"type"` + Data interface{} `json:"data"` +} + +// Hub manages SSE subscriber connections and polls Temporal for workflow updates. +type Hub struct { + tc temporal.WorkflowClient + interval time.Duration + + mu sync.RWMutex + subs map[chan Event]struct{} + lastJSON []byte +} + +// NewHub creates a Hub that polls the given Temporal client at the specified interval. +func NewHub(tc temporal.WorkflowClient, interval time.Duration) *Hub { + return &Hub{ + tc: tc, + interval: interval, + subs: make(map[chan Event]struct{}), + } +} + +// Start begins the background polling loop. It blocks until ctx is canceled. +func (h *Hub) Start(ctx context.Context) { + ticker := time.NewTicker(h.interval) + defer ticker.Stop() + + // Initial poll + h.poll(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.poll(ctx) + } + } +} + +func (h *Hub) poll(ctx context.Context) { + workflows, err := h.tc.ListWorkflows(ctx, temporal.WorkflowFilter{PageSize: 100}) + if err != nil { + log.Printf("SSE poll error: %v", err) + return + } + + h.Broadcast(Event{ + Type: "workflow_update", + Data: map[string]interface{}{ + "workflows": workflows, + }, + }) +} + +// Subscribe registers a new subscriber. +func (h *Hub) Subscribe() chan Event { + ch := make(chan Event, subBufferSize) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +// Unsubscribe removes the given subscriber channel. +func (h *Hub) Unsubscribe(ch chan Event) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() +} + +// Broadcast sends an event to all connected subscribers and stores it as the latest snapshot. +func (h *Hub) Broadcast(event Event) { + eventJSON, err := json.Marshal(event) + + h.mu.Lock() + if err == nil { + h.lastJSON = eventJSON + } + clients := make([]chan Event, 0, len(h.subs)) + for ch := range h.subs { + clients = append(clients, ch) + } + h.mu.Unlock() + + for _, ch := range clients { + select { + case ch <- event: + default: // drop for slow subscribers + } + } +} + +// RunningCount returns the count of running workflows from the last poll. +func (h *Hub) RunningCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + + if h.lastJSON == nil { + return 0 + } + + var event Event + if err := json.Unmarshal(h.lastJSON, &event); err != nil { + return 0 + } + + dataMap, ok := event.Data.(map[string]interface{}) + if !ok { + return 0 + } + + workflows, ok := dataMap["workflows"].([]interface{}) + if !ok { + return 0 + } + + count := 0 + for _, w := range workflows { + wf, ok := w.(map[string]interface{}) + if ok && wf["Status"] == "Running" { + count++ + } + } + return count +} + +// ServeSSE handles the /events SSE endpoint. +func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + ch := h.Subscribe() + defer h.Unsubscribe(ch) + + // Send initial snapshot + h.mu.RLock() + lastJSON := h.lastJSON + h.mu.RUnlock() + + if lastJSON != nil { + fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", lastJSON) + } else { + fmt.Fprintf(w, "event: snapshot\ndata: {}\n\n") + } + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case event := <-ch: + eventJSON, err := json.Marshal(event) + if err != nil { + continue + } + fmt.Fprintf(w, "data: %s\n\n", eventJSON) + flusher.Flush() + } + } +} diff --git a/go/plugins/temporal-mcp/internal/sse/hub_test.go b/go/plugins/temporal-mcp/internal/sse/hub_test.go new file mode 100644 index 000000000..2b26ff1c3 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/sse/hub_test.go @@ -0,0 +1,186 @@ +package sse_test + +import ( + "bufio" + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" +) + +type mockTC struct { + workflows []*temporal.WorkflowSummary + listErr error +} + +func (m *mockTC) ListWorkflows(_ context.Context, _ temporal.WorkflowFilter) ([]*temporal.WorkflowSummary, error) { + if m.listErr != nil { + return nil, m.listErr + } + if m.workflows == nil { + return []*temporal.WorkflowSummary{}, nil + } + return m.workflows, nil +} + +func (m *mockTC) GetWorkflow(_ context.Context, id string) (*temporal.WorkflowDetail, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTC) CancelWorkflow(_ context.Context, _ string) error { return nil } + +func (m *mockTC) SignalWorkflow(_ context.Context, _, _ string, _ interface{}) error { return nil } + +func TestHub_SubscribeUnsubscribe(t *testing.T) { + tc := &mockTC{} + h := sse.NewHub(tc, time.Minute) + ch1 := h.Subscribe() + ch2 := h.Subscribe() + ch3 := h.Subscribe() + + h.Unsubscribe(ch3) + h.Broadcast(sse.Event{Type: "test", Data: "hello"}) + + for i, ch := range []chan sse.Event{ch1, ch2} { + select { + case ev := <-ch: + if ev.Type != "test" { + t.Errorf("subscriber %d: expected test, got %q", i+1, ev.Type) + } + case <-time.After(200 * time.Millisecond): + t.Errorf("subscriber %d: timed out", i+1) + } + } + + select { + case ev := <-ch3: + t.Errorf("unsubscribed channel received: %+v", ev) + case <-time.After(50 * time.Millisecond): + } +} + +func TestHub_ConcurrentSubscribers(t *testing.T) { + tc := &mockTC{} + h := sse.NewHub(tc, time.Minute) + const N = 50 + + channels := make([]chan sse.Event, N) + var wg sync.WaitGroup + for i := 0; i < N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + channels[i] = h.Subscribe() + }(i) + } + wg.Wait() + + h.Broadcast(sse.Event{Type: "concurrent", Data: "test"}) + + for i, ch := range channels { + select { + case ev := <-ch: + if ev.Type != "concurrent" { + t.Errorf("subscriber %d: expected concurrent, got %q", i, ev.Type) + } + case <-time.After(500 * time.Millisecond): + t.Errorf("subscriber %d timed out", i) + } + } +} + +func TestServeSSE_Integration(t *testing.T) { + now := time.Now() + tc := &mockTC{ + workflows: []*temporal.WorkflowSummary{ + {WorkflowID: "wf-1", Status: "Running", StartTime: now}, + }, + } + h := sse.NewHub(tc, time.Minute) + + // Pre-broadcast so there's a snapshot + h.Broadcast(sse.Event{Type: "workflow_update", Data: map[string]interface{}{"workflows": tc.workflows}}) + + srv := httptest.NewServer(http.HandlerFunc(h.ServeSSE)) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Errorf("Content-Type: want text/event-stream, got %q", ct) + } + + lines := make(chan string, 200) + go func() { + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + lines <- scanner.Text() + } + }() + + gotSnapshot := false + deadline := time.After(2 * time.Second) + for !gotSnapshot { + select { + case line := <-lines: + if strings.HasPrefix(line, "event: snapshot") { + gotSnapshot = true + } + case <-deadline: + t.Fatal("timed out waiting for snapshot event") + } + } + + // Trigger another broadcast + h.Broadcast(sse.Event{Type: "workflow_update", Data: map[string]interface{}{"test": true}}) + + gotUpdate := false + deadline2 := time.After(2 * time.Second) + for !gotUpdate { + select { + case line := <-lines: + if strings.HasPrefix(line, "data:") && strings.Contains(line, "workflow_update") { + gotUpdate = true + } + case <-deadline2: + t.Fatal("timed out waiting for workflow_update event") + } + } +} + +func TestHub_Start_Polls(t *testing.T) { + tc := &mockTC{ + workflows: []*temporal.WorkflowSummary{ + {WorkflowID: "wf-1", Status: "Running", StartTime: time.Now()}, + }, + } + h := sse.NewHub(tc, 50*time.Millisecond) + + ch := h.Subscribe() + + ctx, cancel := context.WithCancel(context.Background()) + go h.Start(ctx) + + // Wait for at least one broadcast from polling + select { + case ev := <-ch: + if ev.Type != "workflow_update" { + t.Errorf("expected workflow_update, got %q", ev.Type) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for poll broadcast") + } + + cancel() +} diff --git a/go/plugins/temporal-mcp/internal/temporal/client.go b/go/plugins/temporal-mcp/internal/temporal/client.go new file mode 100644 index 000000000..e3e49f61d --- /dev/null +++ b/go/plugins/temporal-mcp/internal/temporal/client.go @@ -0,0 +1,352 @@ +package temporal + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/client" +) + +// Client wraps the Temporal SDK client for workflow administration. +type Client struct { + client client.Client + namespace string +} + +// NewClient creates a new Temporal client connected to the given host:port. +// It retries with exponential backoff for up to ~60 seconds to handle +// startup ordering (e.g. temporal-ui starting before temporal-server is ready). +func NewClient(hostPort, namespace string) (*Client, error) { + var c client.Client + var err error + + backoff := time.Second + const maxBackoff = 10 * time.Second + const maxAttempts = 10 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + c, err = client.Dial(client.Options{ + HostPort: hostPort, + Namespace: namespace, + }) + if err == nil { + return &Client{client: c, namespace: namespace}, nil + } + + if attempt == maxAttempts { + break + } + + log.Printf("failed to connect to Temporal at %s (attempt %d/%d): %v — retrying in %s", + hostPort, attempt, maxAttempts, err, backoff) + time.Sleep(backoff) + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + + return nil, fmt.Errorf("failed to connect to Temporal at %s after %d attempts: %w", hostPort, maxAttempts, err) +} + +// NewClientFromSDK wraps an existing Temporal SDK client (useful for testing). +func NewClientFromSDK(c client.Client, namespace string) *Client { + return &Client{client: c, namespace: namespace} +} + +// Close closes the underlying Temporal connection. +func (c *Client) Close() { + c.client.Close() +} + +// statusToQuery maps user-friendly status strings to Temporal visibility query fragments. +func statusToQuery(status string) string { + switch strings.ToLower(status) { + case "running": + return "ExecutionStatus = 'Running'" + case "completed": + return "ExecutionStatus = 'Completed'" + case "failed": + return "ExecutionStatus = 'Failed'" + case "canceled": + return "ExecutionStatus = 'Canceled'" + case "terminated": + return "ExecutionStatus = 'Terminated'" + case "timed_out", "timedout": + return "ExecutionStatus = 'TimedOut'" + default: + return "" + } +} + +// executionStatusString converts a Temporal workflow execution status enum to a human-readable string. +func executionStatusString(status enumspb.WorkflowExecutionStatus) string { + switch status { + case enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: + return "Running" + case enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED: + return "Completed" + case enumspb.WORKFLOW_EXECUTION_STATUS_FAILED: + return "Failed" + case enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED: + return "Canceled" + case enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED: + return "Terminated" + case enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: + return "TimedOut" + default: + return "Unknown" + } +} + +// ListWorkflows lists workflow executions matching the given filter. +func (c *Client) ListWorkflows(ctx context.Context, filter WorkflowFilter) ([]*WorkflowSummary, error) { + var queryParts []string + + if sq := statusToQuery(filter.Status); sq != "" { + queryParts = append(queryParts, sq) + } + if filter.AgentName != "" { + queryParts = append(queryParts, fmt.Sprintf("WorkflowId STARTS_WITH 'agent-%s-'", filter.AgentName)) + } + + query := strings.Join(queryParts, " AND ") + + pageSize := filter.PageSize + if pageSize <= 0 { + pageSize = 50 + } + + resp, err := c.client.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + Namespace: c.namespace, + Query: query, + PageSize: int32(pageSize), + NextPageToken: filter.NextToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to list workflows: %w", err) + } + + var workflows []*WorkflowSummary + for _, exec := range resp.Executions { + agentName, sessionID := ParseWorkflowID(exec.Execution.WorkflowId) + summary := &WorkflowSummary{ + WorkflowID: exec.Execution.WorkflowId, + RunID: exec.Execution.RunId, + AgentName: agentName, + SessionID: sessionID, + Status: executionStatusString(exec.Status), + StartTime: exec.StartTime.AsTime(), + TaskQueue: exec.TaskQueue, + } + if exec.CloseTime != nil && exec.CloseTime.IsValid() { + ct := exec.CloseTime.AsTime() + summary.CloseTime = &ct + } + workflows = append(workflows, summary) + } + + return workflows, nil +} + +// GetWorkflow retrieves detailed information about a specific workflow execution. +func (c *Client) GetWorkflow(ctx context.Context, workflowID string) (*WorkflowDetail, error) { + desc, err := c.client.DescribeWorkflowExecution(ctx, workflowID, "") + if err != nil { + return nil, fmt.Errorf("failed to describe workflow %s: %w", workflowID, err) + } + + info := desc.WorkflowExecutionInfo + agentName, sessionID := ParseWorkflowID(workflowID) + + detail := &WorkflowDetail{ + WorkflowSummary: WorkflowSummary{ + WorkflowID: info.Execution.WorkflowId, + RunID: info.Execution.RunId, + AgentName: agentName, + SessionID: sessionID, + Status: executionStatusString(info.Status), + StartTime: info.StartTime.AsTime(), + TaskQueue: info.TaskQueue, + }, + } + if info.CloseTime != nil && info.CloseTime.IsValid() { + ct := info.CloseTime.AsTime() + detail.CloseTime = &ct + } + + // Fetch activity history + detail.Activities = c.fetchActivities(ctx, workflowID, info.Execution.RunId) + + return detail, nil +} + +// fetchActivities extracts activity information from workflow history events. +func (c *Client) fetchActivities(ctx context.Context, workflowID, runID string) []ActivityInfo { + iter := c.client.GetWorkflowHistory(ctx, workflowID, runID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT) + + type activityState struct { + Name string + ToolName string + StartTime time.Time + Attempt int + } + + pending := make(map[int64]*activityState) // scheduledEventId -> state + var activities []ActivityInfo + + for iter.HasNext() { + event, err := iter.Next() + if err != nil { + break + } + + switch { + case event.GetActivityTaskScheduledEventAttributes() != nil: + attrs := event.GetActivityTaskScheduledEventAttributes() + state := &activityState{ + Name: attrs.ActivityType.Name, + } + // Extract tool name from ToolExecuteActivity input payload. + if attrs.ActivityType.Name == "ToolExecuteActivity" { + state.ToolName = extractToolName(attrs.Input) + } + pending[event.EventId] = state + + case event.GetActivityTaskStartedEventAttributes() != nil: + attrs := event.GetActivityTaskStartedEventAttributes() + if state, ok := pending[attrs.ScheduledEventId]; ok { + state.StartTime = event.EventTime.AsTime() + state.Attempt = int(attrs.Attempt) + } + + case event.GetActivityTaskCompletedEventAttributes() != nil: + attrs := event.GetActivityTaskCompletedEventAttributes() + if state, ok := pending[attrs.ScheduledEventId]; ok { + duration := event.EventTime.AsTime().Sub(state.StartTime) + activities = append(activities, ActivityInfo{ + Name: state.Name, + Status: "Completed", + StartTime: state.StartTime, + Duration: duration.String(), + Attempt: state.Attempt, + ToolName: state.ToolName, + }) + delete(pending, attrs.ScheduledEventId) + } + + case event.GetActivityTaskFailedEventAttributes() != nil: + attrs := event.GetActivityTaskFailedEventAttributes() + if state, ok := pending[attrs.ScheduledEventId]; ok { + duration := event.EventTime.AsTime().Sub(state.StartTime) + errMsg := "" + if attrs.Failure != nil { + errMsg = attrs.Failure.Message + } + activities = append(activities, ActivityInfo{ + Name: state.Name, + Status: "Failed", + StartTime: state.StartTime, + Duration: duration.String(), + Attempt: state.Attempt, + Error: errMsg, + ToolName: state.ToolName, + }) + delete(pending, attrs.ScheduledEventId) + } + + case event.GetActivityTaskTimedOutEventAttributes() != nil: + attrs := event.GetActivityTaskTimedOutEventAttributes() + if state, ok := pending[attrs.ScheduledEventId]; ok { + duration := event.EventTime.AsTime().Sub(state.StartTime) + activities = append(activities, ActivityInfo{ + Name: state.Name, + Status: "TimedOut", + StartTime: state.StartTime, + Duration: duration.String(), + Attempt: state.Attempt, + ToolName: state.ToolName, + }) + delete(pending, attrs.ScheduledEventId) + } + + case event.GetActivityTaskCanceledEventAttributes() != nil: + attrs := event.GetActivityTaskCanceledEventAttributes() + if state, ok := pending[attrs.ScheduledEventId]; ok { + duration := event.EventTime.AsTime().Sub(state.StartTime) + activities = append(activities, ActivityInfo{ + Name: state.Name, + Status: "Canceled", + StartTime: state.StartTime, + Duration: duration.String(), + Attempt: state.Attempt, + ToolName: state.ToolName, + }) + delete(pending, attrs.ScheduledEventId) + } + } + } + + // Add any still-pending (running) activities + for _, state := range pending { + if !state.StartTime.IsZero() { + activities = append(activities, ActivityInfo{ + Name: state.Name, + Status: "Running", + StartTime: state.StartTime, + Duration: time.Since(state.StartTime).Truncate(time.Second).String(), + Attempt: state.Attempt, + ToolName: state.ToolName, + }) + } + } + + if activities == nil { + activities = []ActivityInfo{} + } + return activities +} + +// extractToolName attempts to extract the toolName field from a ToolExecuteActivity input payload. +// Temporal encodes activity inputs as Payloads. We try to decode the first payload as JSON +// and extract the "toolName" field. Returns empty string on any error. +func extractToolName(input *common.Payloads) string { + if input == nil || len(input.Payloads) == 0 { + return "" + } + // The first payload contains the serialized ToolRequest. + data := input.Payloads[0].Data + if len(data) == 0 { + return "" + } + var req struct { + ToolName string `json:"toolName"` + } + if err := json.Unmarshal(data, &req); err != nil { + return "" + } + return req.ToolName +} + +// CancelWorkflow cancels a running workflow execution. +func (c *Client) CancelWorkflow(ctx context.Context, workflowID string) error { + if err := c.client.CancelWorkflow(ctx, workflowID, ""); err != nil { + return fmt.Errorf("failed to cancel workflow %s: %w", workflowID, err) + } + return nil +} + +// SignalWorkflow sends a signal to a running workflow execution. +func (c *Client) SignalWorkflow(ctx context.Context, workflowID, signalName string, data interface{}) error { + if err := c.client.SignalWorkflow(ctx, workflowID, "", signalName, data); err != nil { + return fmt.Errorf("failed to signal workflow %s: %w", workflowID, err) + } + return nil +} diff --git a/go/plugins/temporal-mcp/internal/temporal/iface.go b/go/plugins/temporal-mcp/internal/temporal/iface.go new file mode 100644 index 000000000..54a0b16d4 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/temporal/iface.go @@ -0,0 +1,11 @@ +package temporal + +import "context" + +// WorkflowClient defines the operations used by MCP tools, REST handlers, and SSE hub. +type WorkflowClient interface { + ListWorkflows(ctx context.Context, filter WorkflowFilter) ([]*WorkflowSummary, error) + GetWorkflow(ctx context.Context, workflowID string) (*WorkflowDetail, error) + CancelWorkflow(ctx context.Context, workflowID string) error + SignalWorkflow(ctx context.Context, workflowID, signalName string, data interface{}) error +} diff --git a/go/plugins/temporal-mcp/internal/temporal/parse.go b/go/plugins/temporal-mcp/internal/temporal/parse.go new file mode 100644 index 000000000..2eb17aa51 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/temporal/parse.go @@ -0,0 +1,21 @@ +package temporal + +import "strings" + +// ParseWorkflowID extracts agent name and session ID from a workflow ID +// following the pattern "agent-{agentName}-{sessionID}". +// Returns empty strings if the pattern doesn't match. +func ParseWorkflowID(id string) (agentName, sessionID string) { + if !strings.HasPrefix(id, "agent-") { + return "", "" + } + rest := strings.TrimPrefix(id, "agent-") + // Find the last hyphen to split agent name from session ID. + // Agent names may contain hyphens (e.g., "k8s-agent"), so we take + // the last segment as session ID. + idx := strings.LastIndex(rest, "-") + if idx < 0 { + return rest, "" + } + return rest[:idx], rest[idx+1:] +} diff --git a/go/plugins/temporal-mcp/internal/temporal/parse_test.go b/go/plugins/temporal-mcp/internal/temporal/parse_test.go new file mode 100644 index 000000000..5b15e38cd --- /dev/null +++ b/go/plugins/temporal-mcp/internal/temporal/parse_test.go @@ -0,0 +1,61 @@ +package temporal + +import "testing" + +func TestParseWorkflowID(t *testing.T) { + tests := []struct { + name string + id string + wantAgent string + wantSess string + }{ + { + name: "standard pattern", + id: "agent-k8s-agent-abc123", + wantAgent: "k8s-agent", + wantSess: "abc123", + }, + { + name: "simple agent name", + id: "agent-myagent-sess1", + wantAgent: "myagent", + wantSess: "sess1", + }, + { + name: "no prefix", + id: "workflow-123", + wantAgent: "", + wantSess: "", + }, + { + name: "agent prefix only", + id: "agent-onlyname", + wantAgent: "onlyname", + wantSess: "", + }, + { + name: "empty string", + id: "", + wantAgent: "", + wantSess: "", + }, + { + name: "multi-hyphen agent name", + id: "agent-my-k8s-agent-session42", + wantAgent: "my-k8s-agent", + wantSess: "session42", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent, sess := ParseWorkflowID(tt.id) + if agent != tt.wantAgent { + t.Errorf("agent = %q, want %q", agent, tt.wantAgent) + } + if sess != tt.wantSess { + t.Errorf("session = %q, want %q", sess, tt.wantSess) + } + }) + } +} diff --git a/go/plugins/temporal-mcp/internal/temporal/types.go b/go/plugins/temporal-mcp/internal/temporal/types.go new file mode 100644 index 000000000..c6a5eaa22 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/temporal/types.go @@ -0,0 +1,40 @@ +package temporal + +import "time" + +// WorkflowFilter specifies criteria for listing workflows. +type WorkflowFilter struct { + Status string // "running", "completed", "failed", "" (all) + AgentName string // parsed from workflow ID pattern "agent-{name}-{session}" + PageSize int + NextToken []byte +} + +// WorkflowSummary is a lightweight representation of a workflow execution. +type WorkflowSummary struct { + WorkflowID string `json:"WorkflowID"` + RunID string `json:"RunID"` + AgentName string `json:"AgentName"` + SessionID string `json:"SessionID"` + Status string `json:"Status"` + StartTime time.Time `json:"StartTime"` + CloseTime *time.Time `json:"CloseTime,omitempty"` + TaskQueue string `json:"TaskQueue"` +} + +// WorkflowDetail includes the full activity history for a workflow. +type WorkflowDetail struct { + WorkflowSummary + Activities []ActivityInfo `json:"Activities"` +} + +// ActivityInfo describes a single activity execution within a workflow. +type ActivityInfo struct { + Name string `json:"Name"` + Status string `json:"Status"` + StartTime time.Time `json:"StartTime"` + Duration string `json:"Duration"` + Attempt int `json:"Attempt"` + Error string `json:"Error,omitempty"` + ToolName string `json:"ToolName,omitempty"` +} diff --git a/go/plugins/temporal-mcp/internal/ui/embed.go b/go/plugins/temporal-mcp/internal/ui/embed.go new file mode 100644 index 000000000..c09506387 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/ui/embed.go @@ -0,0 +1,33 @@ +package ui + +import ( + "bytes" + _ "embed" + "html" + "net/http" +) + +//go:embed index.html +var indexHTML []byte + +// Config holds UI-specific configuration injected into the HTML. +type Config struct { + WebUIURL string // URL of the official Temporal Web UI (empty = disabled) + Namespace string // Temporal namespace +} + +// Handler returns an http.Handler that serves the embedded SPA with injected config. +func Handler(cfg Config) http.Handler { + // Inject server-side config as a global JS variable before + script := []byte(``) + + rendered := bytes.Replace(indexHTML, []byte(""), script, 1) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(rendered) //nolint:errcheck + }) +} diff --git a/go/plugins/temporal-mcp/internal/ui/embed_test.go b/go/plugins/temporal-mcp/internal/ui/embed_test.go new file mode 100644 index 000000000..77997e3e5 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/ui/embed_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUI_Embedded(t *testing.T) { + if len(indexHTML) == 0 { + t.Fatal("indexHTML is empty — embed directive likely failed") + } + if !strings.Contains(string(indexHTML), "Temporal Workflows") { + t.Error("indexHTML does not contain 'Temporal Workflows'") + } +} + +func TestUI_Handler(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + Handler(Config{WebUIURL: "http://temporal:8080", Namespace: "test"}).ServeHTTP(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/html") { + t.Errorf("expected Content-Type text/html, got %q", ct) + } + body := w.Body.String() + if body == "" { + t.Error("expected non-empty body") + } + if !strings.Contains(body, "Temporal Workflows") { + t.Errorf("expected body to contain 'Temporal Workflows'") + } +} diff --git a/go/plugins/temporal-mcp/internal/ui/index.html b/go/plugins/temporal-mcp/internal/ui/index.html new file mode 100644 index 000000000..8fc751fb3 --- /dev/null +++ b/go/plugins/temporal-mcp/internal/ui/index.html @@ -0,0 +1,742 @@ + + + + + +Temporal Workflows + + + +
+
+ +

Temporal Workflows

+ +
Connecting…
+
+ +
+ + + + +
+ + + + + +
+ +
+ + + + + + + + + + + + + +
AgentWorkflow IDRun IDStatusStart TimeDuration
+ +
+ + + + + + + diff --git a/go/plugins/temporal-mcp/main.go b/go/plugins/temporal-mcp/main.go new file mode 100644 index 000000000..bd7ad48db --- /dev/null +++ b/go/plugins/temporal-mcp/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/config" + temporalmcp "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + log.Printf("temporal-mcp config: addr=%s transport=%s temporal=%s namespace=%s poll=%s log=%s", + cfg.Addr, cfg.Transport, cfg.TemporalHostPort, cfg.TemporalNamespace, cfg.PollInterval, cfg.LogLevel) + + tc, err := temporal.NewClient(cfg.TemporalHostPort, cfg.TemporalNamespace) + if err != nil { + log.Fatalf("failed to create Temporal client: %v", err) + } + defer tc.Close() + + hub := sse.NewHub(tc, cfg.PollInterval) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if cfg.Transport == "stdio" { + log.Printf("starting in stdio transport mode") + mcpServer := temporalmcp.NewServer(tc) + if err := mcpServer.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + log.Fatalf("MCP stdio server error: %v", err) + } + return + } + + // HTTP mode — start SSE polling in background + go hub.Start(ctx) + + srv := NewHTTPServer(cfg, tc, hub) + log.Printf("temporal-mcp listening on %s", cfg.Addr) + + go func() { + <-ctx.Done() + srv.Close() //nolint:errcheck + }() + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } +} diff --git a/go/plugins/temporal-mcp/server.go b/go/plugins/temporal-mcp/server.go new file mode 100644 index 000000000..1afcfdddd --- /dev/null +++ b/go/plugins/temporal-mcp/server.go @@ -0,0 +1,69 @@ +package main + +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" + + temporalapi "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/api" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/config" + temporalmcp "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/mcp" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/ui" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NewHTTPServer constructs the HTTP server with all routes wired. +func NewHTTPServer(cfg *config.Config, tc temporal.WorkflowClient, hub *sse.Hub) *http.Server { + mcpServer := temporalmcp.NewServer(tc) + mcpHandler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return mcpServer + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpHandler) + mux.HandleFunc("/events", hub.ServeSSE) + mux.HandleFunc("/api/workflows", temporalapi.WorkflowsHandler(tc)) + mux.HandleFunc("/api/workflows/", temporalapi.WorkflowHandler(tc)) + // Reverse-proxy to the official Temporal Web UI if configured. + // The Temporal Web UI is configured with TEMPORAL_UI_PUBLIC_PATH={proxyPrefix}/webui + // so it expects the full external path. The proxy rewrites /webui/... to + // {proxyPrefix}/webui/... before forwarding to the upstream Temporal Web UI. + if cfg.WebUIURL != "" { + webuiTarget, _ := url.Parse(cfg.WebUIURL) + prefix := strings.TrimRight(cfg.ProxyPrefix, "/") + webuiProxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = webuiTarget.Scheme + req.URL.Host = webuiTarget.Host + // Prepend proxy prefix so the path matches TEMPORAL_UI_PUBLIC_PATH + if prefix != "" { + req.URL.Path = prefix + req.URL.Path + if req.URL.RawPath != "" { + req.URL.RawPath = prefix + req.URL.RawPath + } + } + req.Host = webuiTarget.Host + }, + } + mux.Handle("/webui/", webuiProxy) + } + + mux.Handle("/", ui.Handler(ui.Config{ + // Link to the proxied Temporal Web UI at /webui/ relative path + WebUIURL: func() string { + if cfg.WebUIURL != "" { + return "webui" + } + return "" + }(), + Namespace: cfg.TemporalNamespace, + })) + + return &http.Server{ + Addr: cfg.Addr, + Handler: mux, + } +} diff --git a/go/plugins/temporal-mcp/server_test.go b/go/plugins/temporal-mcp/server_test.go new file mode 100644 index 000000000..89ed73b23 --- /dev/null +++ b/go/plugins/temporal-mcp/server_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/config" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/sse" + "github.com/kagent-dev/kagent/go/plugins/temporal-mcp/internal/temporal" +) + +// mockWorkflowClient implements temporal.WorkflowClient for testing. +type mockWorkflowClient struct { + workflows []*temporal.WorkflowSummary +} + +func (m *mockWorkflowClient) ListWorkflows(_ context.Context, _ temporal.WorkflowFilter) ([]*temporal.WorkflowSummary, error) { + return m.workflows, nil +} + +func (m *mockWorkflowClient) GetWorkflow(_ context.Context, workflowID string) (*temporal.WorkflowDetail, error) { + for _, w := range m.workflows { + if w.WorkflowID == workflowID { + return &temporal.WorkflowDetail{WorkflowSummary: *w}, nil + } + } + return &temporal.WorkflowDetail{}, nil +} + +func (m *mockWorkflowClient) CancelWorkflow(_ context.Context, _ string) error { + return nil +} + +func (m *mockWorkflowClient) SignalWorkflow(_ context.Context, _, _ string, _ interface{}) error { + return nil +} + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + + tc := &mockWorkflowClient{ + workflows: []*temporal.WorkflowSummary{ + { + WorkflowID: "agent-test-sess1", + RunID: "run-1", + AgentName: "test", + SessionID: "sess1", + Status: "Running", + StartTime: time.Now().Add(-5 * time.Minute), + }, + }, + } + + cfg := &config.Config{Addr: ":0"} + hub := sse.NewHub(tc, 5*time.Second) + srv := NewHTTPServer(cfg, tc, hub) + + return httptest.NewServer(srv.Handler) +} + +func TestHTTPServer_UI(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/html") { + t.Errorf("expected text/html, got %q", ct) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Temporal Workflows") { + t.Error("expected body to contain 'Temporal Workflows'") + } +} + +func TestHTTPServer_APIWorkflows(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/workflows") + if err != nil { + t.Fatalf("GET /api/workflows: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + if result["data"] == nil { + t.Error("expected 'data' field in response") + } +} + +func TestHTTPServer_APIWorkflowDetail(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/workflows/agent-test-sess1") + if err != nil { + t.Fatalf("GET /api/workflows/agent-test-sess1: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + if result["data"] == nil { + t.Error("expected 'data' field in response") + } +} + +func TestHTTPServer_MCP(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/mcp", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST /mcp: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, raw) + } + + raw, _ := io.ReadAll(resp.Body) + sseData := string(raw) + if !strings.Contains(sseData, "data:") { + t.Fatalf("expected SSE data line, got: %q", sseData) + } + + var jsonrpcPayload string + for _, line := range strings.Split(sseData, "\n") { + if strings.HasPrefix(line, "data: ") { + jsonrpcPayload = strings.TrimPrefix(line, "data: ") + break + } + } + if jsonrpcPayload == "" { + t.Fatalf("no data line found in SSE response: %q", sseData) + } + + var result map[string]interface{} + if err := json.Unmarshal([]byte(jsonrpcPayload), &result); err != nil { + t.Fatalf("decode JSON-RPC payload: %v", err) + } + if result["jsonrpc"] != "2.0" { + t.Errorf("expected jsonrpc=2.0, got %v", result["jsonrpc"]) + } +} + +func TestHTTPServer_SSE(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/events", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /events: %v", err) + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Errorf("expected Content-Type text/event-stream, got %q", ct) + } + + buf := make([]byte, 512) + n, _ := resp.Body.Read(buf) + data := string(buf[:n]) + + if !strings.Contains(data, "event: snapshot") { + t.Errorf("expected snapshot event in SSE stream, got: %q", data) + } +} diff --git a/helm/agents/argo-rollouts/templates/agent.yaml b/helm/agents/argo-rollouts/templates/agent.yaml index cb8353172..41920774d 100644 --- a/helm/agents/argo-rollouts/templates/agent.yaml +++ b/helm/agents/argo-rollouts/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: The Argo Rollouts Converter AI Agent specializes in converting Kubernetes Deployments to Argo Rollouts. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: systemMessage: | You are an Argo Rollouts specialist focused on progressive delivery and deployment automation. You diff --git a/helm/agents/argo-rollouts/values.yaml b/helm/agents/argo-rollouts/values.yaml index 720e57897..8330b2415 100644 --- a/helm/agents/argo-rollouts/values.yaml +++ b/helm/agents/argo-rollouts/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/cilium-debug/templates/agent.yaml b/helm/agents/cilium-debug/templates/agent.yaml index 8f6167bab..a4b7d7581 100644 --- a/helm/agents/cilium-debug/templates/agent.yaml +++ b/helm/agents/cilium-debug/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: Cilium debug agent can help with debugging, troubleshooting, and advanced diagnostics of Cilium installations in Kubernetes clusters. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: modelConfig: {{ .Values.modelConfigRef | default (printf "%s" (include "kagent.defaultModelConfigName" .)) }} systemMessage: | diff --git a/helm/agents/cilium-debug/values.yaml b/helm/agents/cilium-debug/values.yaml index 1462d5553..fa19984e9 100644 --- a/helm/agents/cilium-debug/values.yaml +++ b/helm/agents/cilium-debug/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/cilium-manager/templates/agent.yaml b/helm/agents/cilium-manager/templates/agent.yaml index 5ea583c14..a503538a2 100644 --- a/helm/agents/cilium-manager/templates/agent.yaml +++ b/helm/agents/cilium-manager/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: Cilium manager agent knows how to install, configure, monitor, and troubleshoot Cilium in Kubernetes environments type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: modelConfig: {{ .Values.modelConfigRef | default (printf "%s" (include "kagent.defaultModelConfigName" .)) }} systemMessage: |- diff --git a/helm/agents/cilium-manager/values.yaml b/helm/agents/cilium-manager/values.yaml index 1462d5553..fa19984e9 100644 --- a/helm/agents/cilium-manager/values.yaml +++ b/helm/agents/cilium-manager/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/cilium-policy/templates/agent.yaml b/helm/agents/cilium-policy/templates/agent.yaml index 4dfb4881a..9b9774ead 100644 --- a/helm/agents/cilium-policy/templates/agent.yaml +++ b/helm/agents/cilium-policy/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: Cilium policy agent knows how to create CiliumNetworkPolicy and CiliumClusterwideNetworkPolicy resources from natural language type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: modelConfig: {{ .Values.modelConfigRef | default (printf "%s" (include "kagent.defaultModelConfigName" .)) }} systemMessage: |- diff --git a/helm/agents/cilium-policy/values.yaml b/helm/agents/cilium-policy/values.yaml index 720e57897..8330b2415 100644 --- a/helm/agents/cilium-policy/values.yaml +++ b/helm/agents/cilium-policy/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/helm/templates/agent.yaml b/helm/agents/helm/templates/agent.yaml index 0043da4eb..71713da6e 100644 --- a/helm/agents/helm/templates/agent.yaml +++ b/helm/agents/helm/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: The Helm Expert AI Agent specializing in using Helm for Kubernetes cluster management and operations. This agent is equipped with a range of tools to manage Helm releases and troubleshoot Helm-related issues. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: systemMessage: |- # Helm AI Agent System Prompt diff --git a/helm/agents/helm/values.yaml b/helm/agents/helm/values.yaml index 720e57897..8330b2415 100644 --- a/helm/agents/helm/values.yaml +++ b/helm/agents/helm/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/istio/templates/agent.yaml b/helm/agents/istio/templates/agent.yaml index c9ecf2218..d1a94ea26 100644 --- a/helm/agents/istio/templates/agent.yaml +++ b/helm/agents/istio/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: An Istio Expert AI Agent specializing in Istio operations, troubleshooting, and maintenance. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: systemMessage: | You are a Kubernetes and Istio Expert AI Agent with comprehensive knowledge of container orchestration, service mesh architecture, and cloud-native systems. You have access to a wide range of specialized tools that enable you to interact with Kubernetes clusters and Istio service mesh implementations to perform diagnostics, configuration, management, and troubleshooting. diff --git a/helm/agents/istio/values.yaml b/helm/agents/istio/values.yaml index 720e57897..f420b2d61 100644 --- a/helm/agents/istio/values.yaml +++ b/helm/agents/istio/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: true + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/k8s/templates/agent.yaml b/helm/agents/k8s/templates/agent.yaml index 8d2742cfe..7e21a48e9 100644 --- a/helm/agents/k8s/templates/agent.yaml +++ b/helm/agents/k8s/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: An Kubernetes Expert AI Agent specializing in cluster operations, troubleshooting, and maintenance. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: systemMessage: | # Kubernetes AI Agent System Prompt diff --git a/helm/agents/k8s/values.yaml b/helm/agents/k8s/values.yaml index 720e57897..8330b2415 100644 --- a/helm/agents/k8s/values.yaml +++ b/helm/agents/k8s/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/kgateway/templates/agent.yaml b/helm/agents/kgateway/templates/agent.yaml index f833a1b38..5bd78342c 100644 --- a/helm/agents/kgateway/templates/agent.yaml +++ b/helm/agents/kgateway/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: A kgateway Expert, a specialized AI assistant with deep knowledge of kgateway, the cloud-native API gateway built on top of Envoy proxy and the Kubernetes Gateway API. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: systemMessage: | You are kgateway Expert, a specialized AI assistant with deep knowledge of kgateway, the cloud-native API gateway built on top of Envoy proxy and the Kubernetes Gateway API. Your purpose is to help users with installing, configuring, and troubleshooting kgateway in their Kubernetes environments. diff --git a/helm/agents/kgateway/values.yaml b/helm/agents/kgateway/values.yaml index 720e57897..8330b2415 100644 --- a/helm/agents/kgateway/values.yaml +++ b/helm/agents/kgateway/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/observability/templates/agent.yaml b/helm/agents/observability/templates/agent.yaml index f652bb37b..215645a02 100644 --- a/helm/agents/observability/templates/agent.yaml +++ b/helm/agents/observability/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: An Observability-oriented Agent specialized in using Prometheus, Grafana, and Kubernetes for monitoring and observability. This agent is equipped with a range of tools to query Prometheus for metrics, create Grafana dashboards, and verify Kubernetes resources. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: systemMessage: | # Observability AI Agent System Prompt diff --git a/helm/agents/observability/values.yaml b/helm/agents/observability/values.yaml index 1462d5553..fa19984e9 100644 --- a/helm/agents/observability/values.yaml +++ b/helm/agents/observability/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/agents/promql/templates/agent.yaml b/helm/agents/promql/templates/agent.yaml index 6367875a0..22239f664 100644 --- a/helm/agents/promql/templates/agent.yaml +++ b/helm/agents/promql/templates/agent.yaml @@ -8,6 +8,13 @@ metadata: spec: description: GeneratePromQLTool generates PromQL queries from natural language descriptions. type: Declarative + {{- if .Values.temporal.enabled }} + temporal: + enabled: true + {{- if .Values.temporal.workflowTimeout }} + workflowTimeout: {{ .Values.temporal.workflowTimeout }} + {{- end }} + {{- end }} declarative: modelConfig: {{ .Values.modelConfigRef | default (printf "%s" (include "kagent.defaultModelConfigName" .)) }} systemMessage: | diff --git a/helm/agents/promql/values.yaml b/helm/agents/promql/values.yaml index 720e57897..8330b2415 100644 --- a/helm/agents/promql/values.yaml +++ b/helm/agents/promql/values.yaml @@ -1,6 +1,10 @@ modelConfigRef: "" imagePullSecrets: [] +temporal: + enabled: false + workflowTimeout: 3m + resources: requests: diff --git a/helm/kagent-crds/templates/kagent.dev_agentcronjobs.yaml b/helm/kagent-crds/templates/kagent.dev_agentcronjobs.yaml new file mode 100644 index 000000000..d7defdcfb --- /dev/null +++ b/helm/kagent-crds/templates/kagent.dev_agentcronjobs.yaml @@ -0,0 +1,170 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: agentcronjobs.kagent.dev +spec: + group: kagent.dev + names: + kind: AgentCronJob + listKind: AgentCronJobList + plural: agentcronjobs + singular: agentcronjob + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Cron schedule expression. + jsonPath: .spec.schedule + name: Schedule + type: string + - description: Referenced Agent CR name. + jsonPath: .spec.agentRef + name: Agent + type: string + - description: Time of the last execution. + jsonPath: .status.lastRunTime + name: LastRun + type: date + - description: Time of the next scheduled execution. + jsonPath: .status.nextRunTime + name: NextRun + type: date + - description: Result of the last execution. + jsonPath: .status.lastRunResult + name: LastResult + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: AgentCronJob is the Schema for the agentcronjobs API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AgentCronJobSpec defines the desired state of AgentCronJob. + properties: + agentRef: + description: AgentRef is the name of the Agent CR to invoke. Must + be in the same namespace. + minLength: 1 + type: string + prompt: + description: Prompt is the static user message sent to the agent on + each run. + minLength: 1 + type: string + schedule: + description: 'Schedule in standard cron format (5-field: minute hour + day month weekday).' + minLength: 1 + type: string + required: + - agentRef + - prompt + - schedule + type: object + status: + description: AgentCronJobStatus defines the observed state of AgentCronJob. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastRunMessage: + description: LastRunMessage contains error details when LastRunResult + is "Failed". + type: string + lastRunResult: + description: 'LastRunResult is the result of the most recent execution: + "Success" or "Failed".' + type: string + lastRunTime: + description: LastRunTime is the timestamp of the most recent execution. + format: date-time + type: string + lastSessionID: + description: LastSessionID is the session ID created by the most recent + execution. + type: string + nextRunTime: + description: NextRunTime is the calculated timestamp of the next execution. + format: date-time + type: string + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index 8b735e616..5a4259080 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -10213,6 +10213,40 @@ spec: minItems: 1 type: array type: object + temporal: + description: |- + Temporal configures durable workflow execution for this agent. + When enabled, agent execution runs as Temporal workflows with per-turn + activity granularity, crash recovery, and configurable retry policies. + properties: + enabled: + description: Enabled controls whether this agent uses Temporal + for execution. + type: boolean + retryPolicy: + description: RetryPolicy configures activity retry behavior. + properties: + llmMaxAttempts: + description: |- + LLMMaxAttempts is the maximum number of retry attempts for LLM activities. + Default: 5. + format: int32 + minimum: 1 + type: integer + toolMaxAttempts: + description: |- + ToolMaxAttempts is the maximum number of retry attempts for tool activities. + Default: 3. + format: int32 + minimum: 1 + type: integer + type: object + workflowTimeout: + description: |- + WorkflowTimeout is the maximum duration for a workflow execution. + Default: 3m. + type: string + type: object type: allOf: - enum: diff --git a/helm/kagent-crds/templates/kagent.dev_remotemcpservers.yaml b/helm/kagent-crds/templates/kagent.dev_remotemcpservers.yaml index 534c27b35..f23f51dab 100644 --- a/helm/kagent-crds/templates/kagent.dev_remotemcpservers.yaml +++ b/helm/kagent-crds/templates/kagent.dev_remotemcpservers.yaml @@ -176,6 +176,57 @@ spec: type: boolean timeout: type: string + ui: + description: |- + UI defines optional web UI metadata for this MCP server. + When ui.enabled is true, the server's UI is accessible via /_p/{ui.pathPrefix}/ (proxy) + and browser URL /plugins/{ui.pathPrefix} (Next.js wrapper with sidebar + iframe) + properties: + defaultPath: + description: |- + DefaultPath is the initial path to redirect to when the plugin root is loaded. + For example, "/namespaces/kagent" makes the plugin open at that path by default. + type: string + displayName: + description: |- + DisplayName is the human-readable name shown in the sidebar. + Defaults to the RemoteMCPServer name if not specified. + type: string + enabled: + default: false + description: Enabled indicates this MCP server provides a web + UI. + type: boolean + icon: + default: puzzle + description: Icon is a lucide-react icon name (e.g., "kanban", + "git-fork", "database"). + type: string + injectCSS: + description: |- + InjectCSS is custom CSS injected into proxied HTML responses to customize the plugin UI. + For example, `[data-testid="navigation-header"] { display: none !important; }` hides the nav. + type: string + pathPrefix: + description: |- + PathPrefix is the URL path segment used for routing: /_p/{pathPrefix}/ + Must be a valid URL path segment (lowercase alphanumeric + hyphens). + Defaults to the RemoteMCPServer name if not specified. + maxLength: 63 + pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$ + type: string + section: + default: PLUGINS + description: Section is the sidebar section where this plugin + appears. + enum: + - OVERVIEW + - AGENTS + - RESOURCES + - ADMIN + - PLUGINS + type: string + type: object url: minLength: 1 type: string diff --git a/helm/kagent-crds/templates/kagent.dev_workflowruns.yaml b/helm/kagent-crds/templates/kagent.dev_workflowruns.yaml new file mode 100644 index 000000000..e9cec518c --- /dev/null +++ b/helm/kagent-crds/templates/kagent.dev_workflowruns.yaml @@ -0,0 +1,461 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: workflowruns.kagent.dev +spec: + group: kagent.dev + names: + kind: WorkflowRun + listKind: WorkflowRunList + plural: workflowruns + singular: workflowrun + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.workflowTemplateRef + name: Template + type: string + - jsonPath: .status.phase + name: Status + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: WorkflowRun is the Schema for the workflowruns API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WorkflowRunSpec defines the desired state of a WorkflowRun. + properties: + params: + description: Params provides values for template parameters. + items: + description: Param provides a value for a template parameter. + properties: + name: + description: Name of the parameter. + type: string + value: + description: Value of the parameter. + type: string + required: + - name + - value + type: object + type: array + ttlSecondsAfterFinished: + description: TTLSecondsAfterFinished controls automatic deletion after + completion. + format: int32 + type: integer + workflowTemplateRef: + description: WorkflowTemplateRef is the name of the WorkflowTemplate. + type: string + required: + - workflowTemplateRef + type: object + status: + description: WorkflowRunStatus defines the observed state of a WorkflowRun. + properties: + completionTime: + description: CompletionTime is when the workflow finished. + format: date-time + type: string + conditions: + description: Conditions represent the latest available observations. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the most recent generation observed. + format: int64 + type: integer + phase: + description: 'Phase is a derived summary: Pending, Running, Succeeded, + Failed, Cancelled.' + enum: + - Pending + - Running + - Succeeded + - Failed + - Cancelled + type: string + resolvedSpec: + description: ResolvedSpec is the snapshot of the template at run creation. + properties: + defaults: + description: Defaults for step policies when not specified per-step. + properties: + retry: + description: Retry default policy. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types that + should not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout default policy. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time including + retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a single + attempt. + type: string + type: object + type: object + description: + description: Description of the workflow. + type: string + params: + description: Params declares input parameters. + items: + description: ParamSpec declares an input parameter for a workflow + template. + properties: + default: + description: Default value for the parameter. + type: string + description: + description: Description of the parameter. + type: string + enum: + description: Enum restricts the parameter to a set of allowed + values. + items: + type: string + type: array + name: + description: Name is the parameter name. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + type: + allOf: + - enum: + - string + - number + - boolean + - enum: + - string + - number + - boolean + default: string + description: Type is the parameter type. + type: string + required: + - name + type: object + type: array + retention: + description: Retention controls run history cleanup. + properties: + failedRunsHistoryLimit: + default: 5 + description: FailedRunsHistoryLimit is the max number of failed + runs to keep. + format: int32 + type: integer + successfulRunsHistoryLimit: + default: 10 + description: SuccessfulRunsHistoryLimit is the max number + of successful runs to keep. + format: int32 + type: integer + type: object + steps: + description: Steps defines the workflow DAG. + items: + description: StepSpec defines a single step in the workflow + DAG. + properties: + action: + description: Action is the registered activity name (for + type=action). + type: string + agentRef: + description: AgentRef is the kagent Agent name (for type=agent). + type: string + dependsOn: + description: DependsOn lists step names that must complete + before this step runs. + items: + type: string + type: array + name: + description: Name uniquely identifies this step within the + workflow. + pattern: ^[a-z][a-z0-9-]*$ + type: string + onFailure: + default: stop + description: OnFailure determines behavior when this step + fails. + enum: + - stop + - continue + type: string + output: + description: Output configures how step results are stored + in context. + properties: + as: + description: |- + As stores the full step result at context.. + Defaults to step name if omitted. + type: string + keys: + additionalProperties: + type: string + description: Keys maps selected output fields to top-level + context keys. + type: object + type: object + policy: + description: Policy overrides workflow-level defaults for + this step. + properties: + retry: + description: Retry configures retry behavior. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry + delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of + attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry + delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types + that should not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout configures timeout behavior. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time + including retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a + single attempt. + type: string + type: object + type: object + prompt: + description: |- + Prompt is a template rendered before agent invocation (for type=agent). + Supports expression interpolation for params and context values. + type: string + type: + allOf: + - enum: + - action + - agent + - enum: + - action + - agent + description: Type is the step execution mode. + type: string + with: + additionalProperties: + type: string + description: |- + With provides input key-value pairs for the step. + Values support expression interpolation. + type: object + required: + - name + - type + type: object + maxItems: 200 + minItems: 1 + type: array + required: + - steps + type: object + startTime: + description: StartTime is when the Temporal workflow started. + format: date-time + type: string + steps: + description: Steps tracks per-step execution status. + items: + description: StepStatus tracks the execution status of a single + step. + properties: + completionTime: + description: CompletionTime is when the step finished executing. + format: date-time + type: string + message: + description: Message provides additional detail about the step + status. + type: string + name: + description: Name of the step. + type: string + phase: + description: Phase is the current execution phase. + enum: + - Pending + - Running + - Succeeded + - Failed + - Skipped + type: string + retries: + description: Retries is the number of retry attempts made. + format: int32 + type: integer + sessionID: + description: SessionID is the child workflow session ID for + agent steps. + type: string + startTime: + description: StartTime is when the step started executing. + format: date-time + type: string + required: + - name + - phase + type: object + type: array + templateGeneration: + description: TemplateGeneration tracks which generation of the template + was used. + format: int64 + type: integer + temporalWorkflowID: + description: TemporalWorkflowID is the Temporal workflow execution + ID. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm/kagent-crds/templates/kagent.dev_workflowtemplates.yaml b/helm/kagent-crds/templates/kagent.dev_workflowtemplates.yaml new file mode 100644 index 000000000..40518e807 --- /dev/null +++ b/helm/kagent-crds/templates/kagent.dev_workflowtemplates.yaml @@ -0,0 +1,361 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: workflowtemplates.kagent.dev +spec: + group: kagent.dev + names: + kind: WorkflowTemplate + listKind: WorkflowTemplateList + plural: workflowtemplates + singular: workflowtemplate + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.stepCount + name: Steps + type: integer + - jsonPath: .status.validated + name: Validated + type: boolean + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: WorkflowTemplate is the Schema for the workflowtemplates API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: WorkflowTemplateSpec defines the desired state of a WorkflowTemplate. + properties: + defaults: + description: Defaults for step policies when not specified per-step. + properties: + retry: + description: Retry default policy. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types that should + not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout default policy. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time including + retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a single attempt. + type: string + type: object + type: object + description: + description: Description of the workflow. + type: string + params: + description: Params declares input parameters. + items: + description: ParamSpec declares an input parameter for a workflow + template. + properties: + default: + description: Default value for the parameter. + type: string + description: + description: Description of the parameter. + type: string + enum: + description: Enum restricts the parameter to a set of allowed + values. + items: + type: string + type: array + name: + description: Name is the parameter name. + pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$ + type: string + type: + allOf: + - enum: + - string + - number + - boolean + - enum: + - string + - number + - boolean + default: string + description: Type is the parameter type. + type: string + required: + - name + type: object + type: array + retention: + description: Retention controls run history cleanup. + properties: + failedRunsHistoryLimit: + default: 5 + description: FailedRunsHistoryLimit is the max number of failed + runs to keep. + format: int32 + type: integer + successfulRunsHistoryLimit: + default: 10 + description: SuccessfulRunsHistoryLimit is the max number of successful + runs to keep. + format: int32 + type: integer + type: object + steps: + description: Steps defines the workflow DAG. + items: + description: StepSpec defines a single step in the workflow DAG. + properties: + action: + description: Action is the registered activity name (for type=action). + type: string + agentRef: + description: AgentRef is the kagent Agent name (for type=agent). + type: string + dependsOn: + description: DependsOn lists step names that must complete before + this step runs. + items: + type: string + type: array + name: + description: Name uniquely identifies this step within the workflow. + pattern: ^[a-z][a-z0-9-]*$ + type: string + onFailure: + default: stop + description: OnFailure determines behavior when this step fails. + enum: + - stop + - continue + type: string + output: + description: Output configures how step results are stored in + context. + properties: + as: + description: |- + As stores the full step result at context.. + Defaults to step name if omitted. + type: string + keys: + additionalProperties: + type: string + description: Keys maps selected output fields to top-level + context keys. + type: object + type: object + policy: + description: Policy overrides workflow-level defaults for this + step. + properties: + retry: + description: Retry configures retry behavior. + properties: + backoffCoefficient: + default: "2.0" + description: |- + BackoffCoefficient is the multiplier for retry delays. + Serialized as string to avoid float precision issues across languages. + type: string + initialInterval: + default: 1s + description: InitialInterval is the initial retry delay. + type: string + maxAttempts: + default: 3 + description: MaxAttempts is the maximum number of attempts. + format: int32 + type: integer + maximumInterval: + default: 60s + description: MaximumInterval is the maximum retry delay. + type: string + nonRetryableErrors: + description: NonRetryableErrors lists error types that + should not be retried. + items: + type: string + type: array + type: object + timeout: + description: Timeout configures timeout behavior. + properties: + heartbeat: + description: Heartbeat is the max time between heartbeats. + type: string + scheduleToClose: + description: ScheduleToClose is the max total time including + retries. + type: string + startToClose: + default: 5m + description: StartToClose is the max time for a single + attempt. + type: string + type: object + type: object + prompt: + description: |- + Prompt is a template rendered before agent invocation (for type=agent). + Supports expression interpolation for params and context values. + type: string + type: + allOf: + - enum: + - action + - agent + - enum: + - action + - agent + description: Type is the step execution mode. + type: string + with: + additionalProperties: + type: string + description: |- + With provides input key-value pairs for the step. + Values support expression interpolation. + type: object + required: + - name + - type + type: object + maxItems: 200 + minItems: 1 + type: array + required: + - steps + type: object + status: + description: WorkflowTemplateStatus defines the observed state of a WorkflowTemplate. + properties: + conditions: + description: Conditions represent the latest available observations. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the most recent generation observed. + format: int64 + type: integer + stepCount: + description: StepCount is the number of steps in the template. + format: int32 + type: integer + validated: + description: Validated indicates the template passed DAG and reference + validation. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/helm/kagent/Chart-template.yaml b/helm/kagent/Chart-template.yaml index 704817917..997ec7795 100644 --- a/helm/kagent/Chart-template.yaml +++ b/helm/kagent/Chart-template.yaml @@ -20,6 +20,18 @@ dependencies: version: ${VERSION} repository: file://../tools/querydoc condition: tools.querydoc.enabled + - name: kanban-mcp + version: ${VERSION} + repository: file://../tools/kanban-mcp + condition: tools.kanban-mcp.enabled + - name: gitrepo-mcp + version: ${VERSION} + repository: file://../tools/gitrepo-mcp + condition: tools.gitrepo-mcp.enabled + - name: cron-mcp + version: ${VERSION} + repository: file://../tools/cron-mcp + condition: tools.cron-mcp.enabled - name: k8s-agent version: ${VERSION} repository: file://../agents/k8s diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index f99ca754d..2886f6248 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -66,3 +66,10 @@ data: STREAMING_TIMEOUT: {{ .Values.controller.streaming.timeout | quote }} WATCH_NAMESPACES: {{ include "kagent.watchNamespaces" . | quote }} ZAP_LOG_LEVEL: {{ .Values.controller.loglevel | quote }} + {{- if .Values.temporal.enabled }} + TEMPORAL_HOST_ADDR: {{ printf "%s-temporal-server:%d" (include "kagent.fullname" .) (.Values.temporal.server.port | int) | quote }} + NATS_ADDR: {{ printf "nats://%s-nats:%d" (include "kagent.fullname" .) (.Values.nats.port | int) | quote }} + {{- end }} + {{- if index .Values "tools" "gitrepo-mcp" "enabled" }} + GITREPO_MCP_URL: {{ printf "http://%s-gitrepo-mcp:%s" (include "kagent.fullname" .) "8080" | quote }} + {{- end }} diff --git a/helm/kagent/templates/nats-deployment.yaml b/helm/kagent/templates/nats-deployment.yaml new file mode 100644 index 000000000..3e71ce087 --- /dev/null +++ b/helm/kagent/templates/nats-deployment.yaml @@ -0,0 +1,46 @@ +{{- if or .Values.nats.enabled .Values.temporal.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kagent.fullname" . }}-nats + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: nats +spec: + replicas: 1 + selector: + matchLabels: + {{- include "kagent.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: nats + template: + metadata: + labels: + {{- include "kagent.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: nats + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: nats + image: {{ .Values.nats.image | quote }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + ports: + - name: client + containerPort: {{ .Values.nats.port }} + protocol: TCP + resources: + {{- toYaml .Values.nats.resources | nindent 12 }} + livenessProbe: + tcpSocket: + port: client + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + tcpSocket: + port: client + initialDelaySeconds: 5 + periodSeconds: 10 +{{- end }} diff --git a/helm/kagent/templates/nats-service.yaml b/helm/kagent/templates/nats-service.yaml new file mode 100644 index 000000000..ad3e7c9e3 --- /dev/null +++ b/helm/kagent/templates/nats-service.yaml @@ -0,0 +1,20 @@ +{{- if or .Values.nats.enabled .Values.temporal.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent.fullname" . }}-nats + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: nats +spec: + type: ClusterIP + ports: + - name: client + port: {{ .Values.nats.port }} + targetPort: client + protocol: TCP + selector: + {{- include "kagent.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: nats +{{- end }} diff --git a/helm/kagent/templates/rbac/clusterrole.yaml b/helm/kagent/templates/rbac/clusterrole.yaml index 61d8eeb52..14e48cb2d 100644 --- a/helm/kagent/templates/rbac/clusterrole.yaml +++ b/helm/kagent/templates/rbac/clusterrole.yaml @@ -8,6 +8,7 @@ rules: - apiGroups: - kagent.dev resources: + - agentcronjobs - agents - modelconfigs - modelproviderconfigs @@ -15,6 +16,8 @@ rules: - memories - remotemcpservers - mcpservers + - workflowtemplates + - workflowruns verbs: - get - list @@ -22,6 +25,7 @@ rules: - apiGroups: - kagent.dev resources: + - agentcronjobs/finalizers - agents/finalizers - modelconfigs/finalizers - modelproviderconfigs/finalizers @@ -29,11 +33,14 @@ rules: - memories/finalizers - remotemcpservers/finalizers - mcpservers/finalizers + - workflowtemplates/finalizers + - workflowruns/finalizers verbs: - update - apiGroups: - kagent.dev resources: + - agentcronjobs/status - agents/status - modelconfigs/status - modelproviderconfigs/status @@ -41,6 +48,8 @@ rules: - memories/status - remotemcpservers/status - mcpservers/status + - workflowtemplates/status + - workflowruns/status verbs: - get - patch @@ -105,6 +114,7 @@ rules: - apiGroups: - kagent.dev resources: + - agentcronjobs - agents - modelconfigs - modelproviderconfigs @@ -112,6 +122,8 @@ rules: - memories - remotemcpservers - mcpservers + - workflowtemplates + - workflowruns verbs: - create - update @@ -120,6 +132,7 @@ rules: - apiGroups: - kagent.dev resources: + - agentcronjobs/finalizers - agents/finalizers - modelconfigs/finalizers - modelproviderconfigs/finalizers @@ -127,6 +140,8 @@ rules: - memories/finalizers - remotemcpservers/finalizers - mcpservers/finalizers + - workflowtemplates/finalizers + - workflowruns/finalizers verbs: - update - apiGroups: diff --git a/helm/kagent/templates/temporal-mcp-deployment.yaml b/helm/kagent/templates/temporal-mcp-deployment.yaml new file mode 100644 index 000000000..63c8fbeb7 --- /dev/null +++ b/helm/kagent/templates/temporal-mcp-deployment.yaml @@ -0,0 +1,55 @@ +{{- if and .Values.temporal.enabled .Values.temporal.mcp.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kagent.fullname" . }}-temporal-mcp + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-mcp +spec: + replicas: 1 + selector: + matchLabels: + {{- include "kagent.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: temporal-mcp + template: + metadata: + labels: + {{- include "kagent.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: temporal-mcp + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: temporal-mcp + image: {{ .Values.temporal.mcp.image | quote }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + ports: + - name: http + containerPort: {{ .Values.temporal.mcp.port }} + protocol: TCP + env: + - name: TEMPORAL_HOST_PORT + value: "{{ include "kagent.fullname" . }}-temporal-server:{{ .Values.temporal.server.port }}" + - name: TEMPORAL_ADDR + value: ":{{ .Values.temporal.mcp.port }}" + - name: TEMPORAL_NAMESPACE + value: {{ .Values.temporal.server.namespace | quote }} + {{- if .Values.temporal.ui.enabled }} + - name: TEMPORAL_WEBUI_URL + value: "http://{{ include "kagent.fullname" . }}-temporal-ui:{{ .Values.temporal.ui.port }}" + - name: TEMPORAL_PROXY_PREFIX + value: "/_p/temporal" + {{- end }} + resources: + {{- toYaml .Values.temporal.mcp.resources | nindent 12 }} + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 +{{- end }} diff --git a/helm/kagent/templates/temporal-mcp-remotemcpserver.yaml b/helm/kagent/templates/temporal-mcp-remotemcpserver.yaml new file mode 100644 index 000000000..800f287b4 --- /dev/null +++ b/helm/kagent/templates/temporal-mcp-remotemcpserver.yaml @@ -0,0 +1,25 @@ +{{- if and .Values.temporal.enabled .Values.temporal.mcp.enabled }} +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "kagent.fullname" . }}-temporal-mcp + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-mcp +spec: + description: Temporal Workflow UI for monitoring agent workflow executions + protocol: STREAMABLE_HTTP + sseReadTimeout: 5m0s + terminateOnClose: true + timeout: 30s + url: {{ printf "http://%s-temporal-mcp.%s:%d" (include "kagent.fullname" .) (include "kagent.namespace" .) (.Values.temporal.mcp.port | int) }} + ui: + enabled: true + pathPrefix: "temporal" + displayName: "Workflows" + icon: "git-branch" + section: "AGENTS" + defaultPath: {{ printf "/namespaces/%s/workflows" (.Values.temporal.namespace | default "kagent") | quote }} + injectCSS: '[data-testid="navigation-header"] { display: none !important; }' +{{- end }} diff --git a/helm/kagent/templates/temporal-mcp-service.yaml b/helm/kagent/templates/temporal-mcp-service.yaml new file mode 100644 index 000000000..6c3311ec1 --- /dev/null +++ b/helm/kagent/templates/temporal-mcp-service.yaml @@ -0,0 +1,20 @@ +{{- if and .Values.temporal.enabled .Values.temporal.mcp.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent.fullname" . }}-temporal-mcp + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-mcp +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.temporal.mcp.port }} + targetPort: http + protocol: TCP + selector: + {{- include "kagent.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: temporal-mcp +{{- end }} diff --git a/helm/kagent/templates/temporal-server-deployment.yaml b/helm/kagent/templates/temporal-server-deployment.yaml new file mode 100644 index 000000000..95fd874b1 --- /dev/null +++ b/helm/kagent/templates/temporal-server-deployment.yaml @@ -0,0 +1,92 @@ +{{- if .Values.temporal.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kagent.fullname" . }}-temporal-server + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-server +spec: + replicas: 1 + selector: + matchLabels: + {{- include "kagent.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: temporal-server + template: + metadata: + labels: + {{- include "kagent.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: temporal-server + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if eq .Values.temporal.persistence.driver "sqlite" }} + volumes: + - name: temporal-data + emptyDir: + sizeLimit: 500Mi + {{- end }} + containers: + - name: temporal-server + image: {{ .Values.temporal.server.image | quote }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + {{- if eq .Values.temporal.persistence.driver "sqlite" }} + command: ["temporal"] + args: + - "server" + - "start-dev" + - "--headless" + - "--ip" + - "0.0.0.0" + - "--port" + - {{ .Values.temporal.server.port | quote }} + - "--db-filename" + - "/temporal-data/temporal.db" + - "--namespace" + - {{ .Values.temporal.server.namespace | quote }} + {{- end }} + ports: + - name: grpc + containerPort: {{ .Values.temporal.server.port }} + protocol: TCP + {{- if eq .Values.temporal.persistence.driver "postgresql" }} + env: + - name: DB + value: postgres12 + - name: DB_PORT + value: {{ .Values.temporal.persistence.postgresql.port | quote }} + - name: DBNAME + value: {{ .Values.temporal.persistence.postgresql.database | quote }} + - name: TEMPORAL_ADDRESS + value: "0.0.0.0:{{ .Values.temporal.server.port }}" + - name: POSTGRES_SEEDS + value: {{ .Values.temporal.persistence.postgresql.host | quote }} + - name: POSTGRES_USER + value: {{ .Values.temporal.persistence.postgresql.user | quote }} + {{- if .Values.temporal.persistence.postgresql.existingSecret }} + - name: POSTGRES_PWD + valueFrom: + secretKeyRef: + name: {{ .Values.temporal.persistence.postgresql.existingSecret }} + key: {{ .Values.temporal.persistence.postgresql.existingSecretKey }} + {{- else }} + - name: POSTGRES_PWD + value: {{ .Values.temporal.persistence.postgresql.password | quote }} + {{- end }} + {{- end }} + resources: + {{- toYaml .Values.temporal.server.resources | nindent 12 }} + {{- if eq .Values.temporal.persistence.driver "sqlite" }} + volumeMounts: + - name: temporal-data + mountPath: /temporal-data + {{- end }} + readinessProbe: + tcpSocket: + port: grpc + initialDelaySeconds: 15 + periodSeconds: 10 +{{- end }} diff --git a/helm/kagent/templates/temporal-server-service.yaml b/helm/kagent/templates/temporal-server-service.yaml new file mode 100644 index 000000000..c8baf65ca --- /dev/null +++ b/helm/kagent/templates/temporal-server-service.yaml @@ -0,0 +1,20 @@ +{{- if .Values.temporal.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent.fullname" . }}-temporal-server + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-server +spec: + type: ClusterIP + ports: + - name: grpc + port: {{ .Values.temporal.server.port }} + targetPort: grpc + protocol: TCP + selector: + {{- include "kagent.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: temporal-server +{{- end }} diff --git a/helm/kagent/templates/temporal-ui-deployment.yaml b/helm/kagent/templates/temporal-ui-deployment.yaml new file mode 100644 index 000000000..ea5ac792a --- /dev/null +++ b/helm/kagent/templates/temporal-ui-deployment.yaml @@ -0,0 +1,51 @@ +{{- if and .Values.temporal.enabled .Values.temporal.ui.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kagent.fullname" . }}-temporal-ui + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-ui +spec: + replicas: 1 + selector: + matchLabels: + {{- include "kagent.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: temporal-ui + template: + metadata: + labels: + {{- include "kagent.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: temporal-ui + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: temporal-ui + image: {{ .Values.temporal.ui.image | quote }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + ports: + - name: http + containerPort: {{ .Values.temporal.ui.port }} + protocol: TCP + env: + - name: TEMPORAL_ADDRESS + value: "{{ include "kagent.fullname" . }}-temporal-server:{{ .Values.temporal.server.port }}" + - name: TEMPORAL_UI_PORT + value: {{ .Values.temporal.ui.port | quote }} + - name: TEMPORAL_UI_PUBLIC_PATH + value: "/_p/temporal/webui" + - name: TEMPORAL_CORS_ORIGINS + value: "http://localhost:3000" + resources: + {{- toYaml .Values.temporal.ui.resources | nindent 12 }} + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 +{{- end }} diff --git a/helm/kagent/templates/temporal-ui-service.yaml b/helm/kagent/templates/temporal-ui-service.yaml new file mode 100644 index 000000000..e4fd19b40 --- /dev/null +++ b/helm/kagent/templates/temporal-ui-service.yaml @@ -0,0 +1,20 @@ +{{- if and .Values.temporal.enabled .Values.temporal.ui.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent.fullname" . }}-temporal-ui + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: temporal-ui +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.temporal.ui.port }} + targetPort: http + protocol: TCP + selector: + {{- include "kagent.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: temporal-ui +{{- end }} diff --git a/helm/kagent/tests/temporal_test.yaml b/helm/kagent/tests/temporal_test.yaml new file mode 100644 index 000000000..522b252ea --- /dev/null +++ b/helm/kagent/tests/temporal_test.yaml @@ -0,0 +1,287 @@ +suite: test temporal infrastructure +templates: + - templates/temporal-server-deployment.yaml + - templates/temporal-server-service.yaml + - templates/temporal-mcp-deployment.yaml + - templates/temporal-mcp-service.yaml + - templates/temporal-mcp-remotemcpserver.yaml + - templates/temporal-ui-deployment.yaml + - templates/temporal-ui-service.yaml + - templates/nats-deployment.yaml + - templates/nats-service.yaml + - templates/controller-configmap.yaml + +tests: + - it: should not render any temporal resources when disabled + set: + temporal: + enabled: false + asserts: + - hasDocuments: + count: 1 + template: templates/controller-configmap.yaml + - hasDocuments: + count: 0 + template: templates/temporal-server-deployment.yaml + - hasDocuments: + count: 0 + template: templates/temporal-server-service.yaml + - hasDocuments: + count: 0 + template: templates/temporal-mcp-deployment.yaml + - hasDocuments: + count: 0 + template: templates/temporal-mcp-service.yaml + - hasDocuments: + count: 0 + template: templates/temporal-mcp-remotemcpserver.yaml + - hasDocuments: + count: 0 + template: templates/temporal-ui-deployment.yaml + - hasDocuments: + count: 0 + template: templates/temporal-ui-service.yaml + + - it: should not render nats when both temporal and nats disabled + set: + temporal: + enabled: false + nats: + enabled: false + asserts: + - hasDocuments: + count: 0 + template: templates/nats-deployment.yaml + - hasDocuments: + count: 0 + template: templates/nats-service.yaml + + - it: should render all temporal resources when enabled + set: + temporal: + enabled: true + asserts: + - hasDocuments: + count: 1 + template: templates/temporal-server-deployment.yaml + - hasDocuments: + count: 1 + template: templates/temporal-server-service.yaml + - hasDocuments: + count: 1 + template: templates/temporal-mcp-deployment.yaml + - hasDocuments: + count: 1 + template: templates/temporal-mcp-service.yaml + - hasDocuments: + count: 1 + template: templates/temporal-mcp-remotemcpserver.yaml + - hasDocuments: + count: 1 + template: templates/temporal-ui-deployment.yaml + - hasDocuments: + count: 1 + template: templates/temporal-ui-service.yaml + - hasDocuments: + count: 1 + template: templates/nats-deployment.yaml + - hasDocuments: + count: 1 + template: templates/nats-service.yaml + + - it: should use SQLite start-dev mode by default + set: + temporal: + enabled: true + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: temporal-data + emptyDir: + sizeLimit: 500Mi + template: templates/temporal-server-deployment.yaml + - contains: + path: spec.template.spec.containers[0].args + content: + "--db-filename" + template: templates/temporal-server-deployment.yaml + - contains: + path: spec.template.spec.containers[0].args + content: + "/temporal-data/temporal.db" + template: templates/temporal-server-deployment.yaml + - isNull: + path: spec.template.spec.containers[0].env + template: templates/temporal-server-deployment.yaml + + - it: should use PostgreSQL when configured + set: + temporal: + enabled: true + persistence: + driver: postgresql + postgresql: + host: pg-host + port: 5432 + database: temporal_db + user: temporal_user + password: secret123 + asserts: + - isNull: + path: spec.template.spec.volumes + template: templates/temporal-server-deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: DB + value: postgres12 + template: templates/temporal-server-deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: POSTGRES_SEEDS + value: pg-host + template: templates/temporal-server-deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: DBNAME + value: temporal_db + template: templates/temporal-server-deployment.yaml + + - it: should use existingSecret for PostgreSQL password when set + set: + temporal: + enabled: true + persistence: + driver: postgresql + postgresql: + host: pg-host + existingSecret: my-pg-secret + existingSecretKey: PG_PASSWORD + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: POSTGRES_PWD + valueFrom: + secretKeyRef: + name: my-pg-secret + key: PG_PASSWORD + template: templates/temporal-server-deployment.yaml + + - it: should set TEMPORAL_ADDRESS in Temporal UI deployment + set: + temporal: + enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TEMPORAL_ADDRESS + value: "RELEASE-NAME-temporal-server:7233" + template: templates/temporal-ui-deployment.yaml + + - it: should create RemoteMCPServer with correct UI config + set: + temporal: + enabled: true + asserts: + - equal: + path: spec.ui.pathPrefix + value: temporal + template: templates/temporal-mcp-remotemcpserver.yaml + - equal: + path: spec.ui.displayName + value: "Workflows" + template: templates/temporal-mcp-remotemcpserver.yaml + - equal: + path: spec.ui.enabled + value: true + template: templates/temporal-mcp-remotemcpserver.yaml + - equal: + path: spec.ui.section + value: "AGENTS" + template: templates/temporal-mcp-remotemcpserver.yaml + + - it: should not render Temporal MCP when mcp.enabled is false + set: + temporal: + enabled: true + mcp: + enabled: false + asserts: + - hasDocuments: + count: 0 + template: templates/temporal-mcp-deployment.yaml + - hasDocuments: + count: 0 + template: templates/temporal-mcp-service.yaml + - hasDocuments: + count: 0 + template: templates/temporal-mcp-remotemcpserver.yaml + + - it: should not render Temporal UI when ui.enabled is false + set: + temporal: + enabled: true + ui: + enabled: false + asserts: + - hasDocuments: + count: 0 + template: templates/temporal-ui-deployment.yaml + - hasDocuments: + count: 0 + template: templates/temporal-ui-service.yaml + + - it: should inject TEMPORAL_HOST_ADDR and NATS_ADDR in controller configmap when temporal enabled + set: + temporal: + enabled: true + asserts: + - equal: + path: data.TEMPORAL_HOST_ADDR + value: "RELEASE-NAME-temporal-server:7233" + template: templates/controller-configmap.yaml + - equal: + path: data.NATS_ADDR + value: "nats://RELEASE-NAME-nats:4222" + template: templates/controller-configmap.yaml + + - it: should not inject TEMPORAL_HOST_ADDR in controller configmap when temporal disabled + set: + temporal: + enabled: false + asserts: + - notExists: + path: data.TEMPORAL_HOST_ADDR + template: templates/controller-configmap.yaml + - notExists: + path: data.NATS_ADDR + template: templates/controller-configmap.yaml + + - it: should render nats when only nats.enabled is true + set: + temporal: + enabled: false + nats: + enabled: true + asserts: + - hasDocuments: + count: 1 + template: templates/nats-deployment.yaml + - hasDocuments: + count: 1 + template: templates/nats-service.yaml + + - it: should use correct NATS image + set: + nats: + enabled: true + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "nats:2-alpine" + template: templates/nats-deployment.yaml diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 1c4715064..35dc84243 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -73,7 +73,7 @@ controller: a2aBaseUrl: "" agentImage: registry: "" - repository: kagent-dev/kagent/app + repository: kagent-dev/kagent/golang-adk tag: "" # Will default to global, then Chart version pullPolicy: "" # -- The image used by the skills-init container to clone skills from Git and pull OCI skill images. @@ -257,6 +257,8 @@ proxy: agents: k8s-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -266,6 +268,8 @@ agents: memory: 1Gi kgateway-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -275,6 +279,8 @@ agents: memory: 1Gi istio-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -284,6 +290,8 @@ agents: memory: 1Gi promql-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -293,6 +301,8 @@ agents: memory: 1Gi observability-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -302,6 +312,8 @@ agents: memory: 1Gi argo-rollouts-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -311,6 +323,8 @@ agents: memory: 1Gi helm-agent: enabled: true + temporal: + enabled: true resources: requests: cpu: 100m @@ -319,7 +333,9 @@ agents: cpu: 1000m memory: 1Gi cilium-policy-agent: - enabled: true + enabled: false + temporal: + enabled: true resources: requests: cpu: 100m @@ -328,7 +344,9 @@ agents: cpu: 1000m memory: 1Gi cilium-manager-agent: - enabled: true + enabled: false + temporal: + enabled: true resources: requests: cpu: 100m @@ -337,7 +355,9 @@ agents: cpu: 1000m memory: 1Gi cilium-debug-agent: - enabled: true + enabled: false + temporal: + enabled: true resources: requests: cpu: 100m @@ -355,6 +375,12 @@ tools: enabled: true querydoc: enabled: true + kanban-mcp: + enabled: true + gitrepo-mcp: + enabled: true + cron-mcp: + enabled: true grafana-mcp: grafana: @@ -387,6 +413,73 @@ querydoc: openai: apiKey: "" +# ============================================================================== +# TEMPORAL WORKFLOW ENGINE +# ============================================================================== + +temporal: + enabled: true + server: + image: temporalio/auto-setup:1.26.2 + host: temporal-server + port: 7233 + namespace: kagent + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + persistence: + driver: sqlite # "sqlite" (dev) or "postgresql" (prod) + postgresql: + host: pgsql-postgresql.kagent.svc.cluster.local + port: 5432 + database: temporal + user: postgres + password: "kagent" + existingSecret: "" + existingSecretKey: TEMPORAL_DB_PASSWORD + mcp: + enabled: true + image: temporalio/ui:2.34.0 + port: 8080 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + ui: + enabled: true + image: temporalio/ui:2.34.0 + port: 8081 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + +# ============================================================================== +# NATS STREAMING +# ============================================================================== + +nats: + enabled: true + image: nats:2-alpine + port: 4222 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + # ============================================================================== # OBSERVABILITY # ============================================================================== diff --git a/helm/tools/cron-mcp/Chart-template.yaml b/helm/tools/cron-mcp/Chart-template.yaml new file mode 100644 index 000000000..5b45efe99 --- /dev/null +++ b/helm/tools/cron-mcp/Chart-template.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: cron-mcp +description: MCP server for Cron Job management +type: application +version: ${VERSION} diff --git a/helm/tools/cron-mcp/templates/_helpers.tpl b/helm/tools/cron-mcp/templates/_helpers.tpl new file mode 100644 index 000000000..7c85fda54 --- /dev/null +++ b/helm/tools/cron-mcp/templates/_helpers.tpl @@ -0,0 +1,67 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "cron-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "cron-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "cron-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "cron-mcp.labels" -}} +helm.sh/chart: {{ include "cron-mcp.chart" . }} +{{ include "cron-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "cron-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cron-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "cron-mcp.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cron-mcp.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the server URL for MCP +*/}} +{{- define "cron-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "cron-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} diff --git a/helm/tools/cron-mcp/templates/configmap.yaml b/helm/tools/cron-mcp/templates/configmap.yaml new file mode 100644 index 000000000..dac6ab6b0 --- /dev/null +++ b/helm/tools/cron-mcp/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cron-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "cron-mcp.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/helm/tools/cron-mcp/templates/deployment.yaml b/helm/tools/cron-mcp/templates/deployment.yaml new file mode 100644 index 000000000..92fcdfb38 --- /dev/null +++ b/helm/tools/cron-mcp/templates/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cron-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "cron-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "cron-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "cron-mcp.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "cron-mcp.serviceAccountName" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: cron-mcp + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }} + {{- with .Values.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "cron-mcp.fullname" . }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/tools/cron-mcp/templates/remotemcpserver.yaml b/helm/tools/cron-mcp/templates/remotemcpserver.yaml new file mode 100644 index 000000000..c9467e0c1 --- /dev/null +++ b/helm/tools/cron-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,20 @@ +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "cron-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "cron-mcp.labels" . | nindent 4 }} +spec: + description: Cron job scheduler MCP server + protocol: STREAMABLE_HTTP + sseReadTimeout: 5m0s + terminateOnClose: true + timeout: 30s + url: {{ include "cron-mcp.serverUrl" . }} + ui: + enabled: true + pathPrefix: "cron" + displayName: "Cron Jobs" + icon: "clock" + section: "AGENTS" diff --git a/helm/tools/cron-mcp/templates/service.yaml b/helm/tools/cron-mcp/templates/service.yaml new file mode 100644 index 000000000..843e8db2c --- /dev/null +++ b/helm/tools/cron-mcp/templates/service.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cron-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "cron-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "cron-mcp.selectorLabels" . | nindent 4 }} +--- +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "cron-mcp.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "cron-mcp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/tools/cron-mcp/values.yaml b/helm/tools/cron-mcp/values.yaml new file mode 100644 index 000000000..cb4c38884 --- /dev/null +++ b/helm/tools/cron-mcp/values.yaml @@ -0,0 +1,47 @@ +replicas: 1 + +image: + registry: localhost:5001 + repository: kagent-dev/kagent/cron-mcp + pullPolicy: Always + tag: "" + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +securityContext: {} + +tolerations: [] + +nodeSelector: {} + +service: + type: ClusterIP + port: 8080 + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +args: [] + +volumes: [] + +volumeMounts: [] + +config: + CRON_ADDR: ":8080" + CRON_TRANSPORT: "http" + CRON_DB_TYPE: "sqlite" + CRON_DB_PATH: "/data/cron.db" + CRON_LOG_LEVEL: "info" + CRON_SHELL: "/bin/sh" diff --git a/helm/tools/gitrepo-mcp/Chart-template.yaml b/helm/tools/gitrepo-mcp/Chart-template.yaml new file mode 100644 index 000000000..b3826dcf4 --- /dev/null +++ b/helm/tools/gitrepo-mcp/Chart-template.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: gitrepo-mcp +description: MCP server for Git repository indexing and search +type: application +version: ${VERSION} diff --git a/helm/tools/gitrepo-mcp/templates/_helpers.tpl b/helm/tools/gitrepo-mcp/templates/_helpers.tpl new file mode 100644 index 000000000..f81ece4c7 --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/_helpers.tpl @@ -0,0 +1,67 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gitrepo-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "gitrepo-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gitrepo-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gitrepo-mcp.labels" -}} +helm.sh/chart: {{ include "gitrepo-mcp.chart" . }} +{{ include "gitrepo-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gitrepo-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gitrepo-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "gitrepo-mcp.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "gitrepo-mcp.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the server URL for MCP +*/}} +{{- define "gitrepo-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "gitrepo-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} diff --git a/helm/tools/gitrepo-mcp/templates/configmap.yaml b/helm/tools/gitrepo-mcp/templates/configmap.yaml new file mode 100644 index 000000000..3161203b0 --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/helm/tools/gitrepo-mcp/templates/cronjob.yaml b/helm/tools/gitrepo-mcp/templates/cronjob.yaml new file mode 100644 index 000000000..38430ccfa --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/cronjob.yaml @@ -0,0 +1,25 @@ +{{- if .Values.cronJob.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "gitrepo-mcp.fullname" . }}-sync + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.cronJob.schedule | quote }} + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: sync + image: {{ .Values.cronJob.image }} + command: + - /bin/sh + - -c + - | + curl -sf -X POST http://{{ include "gitrepo-mcp.fullname" . }}:{{ .Values.service.port }}/api/sync-all +{{- end }} diff --git a/helm/tools/gitrepo-mcp/templates/deployment.yaml b/helm/tools/gitrepo-mcp/templates/deployment.yaml new file mode 100644 index 000000000..bc44fa8b4 --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/deployment.yaml @@ -0,0 +1,76 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "gitrepo-mcp.serviceAccountName" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: gitrepo-mcp + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }} + {{- with .Values.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: data + mountPath: /data + {{- end }} + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "gitrepo-mcp.fullname" . }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + volumes: + {{- if .Values.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ include "gitrepo-mcp.fullname" . }} + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/tools/gitrepo-mcp/templates/pvc.yaml b/helm/tools/gitrepo-mcp/templates/pvc.yaml new file mode 100644 index 000000000..a4c3e246b --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/pvc.yaml @@ -0,0 +1,18 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/helm/tools/gitrepo-mcp/templates/remotemcpserver.yaml b/helm/tools/gitrepo-mcp/templates/remotemcpserver.yaml new file mode 100644 index 000000000..6d956154d --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,21 @@ +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + description: Git repository indexing and search MCP server + protocol: STREAMABLE_HTTP + sseReadTimeout: 5m0s + terminateOnClose: true + timeout: 30s + url: {{ include "gitrepo-mcp.serverUrl" . }} + ui: + enabled: true + pathPrefix: "gitrepos" + displayName: "Git Repos" + icon: "git-branch" + section: "AGENTS" + defaultPath: "/ui/" diff --git a/helm/tools/gitrepo-mcp/templates/service.yaml b/helm/tools/gitrepo-mcp/templates/service.yaml new file mode 100644 index 000000000..9c91cbc4a --- /dev/null +++ b/helm/tools/gitrepo-mcp/templates/service.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gitrepo-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "gitrepo-mcp.selectorLabels" . | nindent 4 }} +--- +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "gitrepo-mcp.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gitrepo-mcp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/tools/gitrepo-mcp/values.yaml b/helm/tools/gitrepo-mcp/values.yaml new file mode 100644 index 000000000..1d9107bee --- /dev/null +++ b/helm/tools/gitrepo-mcp/values.yaml @@ -0,0 +1,56 @@ +replicas: 1 + +image: + registry: localhost:5001 + repository: kagent-dev/kagent/gitrepo-mcp + pullPolicy: Always + tag: "" + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +securityContext: {} + +tolerations: [] + +nodeSelector: {} + +service: + type: ClusterIP + port: 8080 + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + +args: + - serve + +volumes: [] + +volumeMounts: [] + +config: + GITREPO_ADDR: ":8080" + GITREPO_TRANSPORT: "http" + GITREPO_DATA_DIR: "/data" + +persistence: + enabled: true + size: 10Gi + storageClass: "" + accessMode: ReadWriteOnce + +cronJob: + enabled: false + schedule: "0 */6 * * *" + image: curlimages/curl:8.5.0 diff --git a/helm/tools/kanban-mcp/Chart-template.yaml b/helm/tools/kanban-mcp/Chart-template.yaml new file mode 100644 index 000000000..95ee9320d --- /dev/null +++ b/helm/tools/kanban-mcp/Chart-template.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: kanban-mcp +description: MCP server for Kanban task board +type: application +version: ${VERSION} diff --git a/helm/tools/kanban-mcp/templates/_helpers.tpl b/helm/tools/kanban-mcp/templates/_helpers.tpl new file mode 100644 index 000000000..50f1cedc9 --- /dev/null +++ b/helm/tools/kanban-mcp/templates/_helpers.tpl @@ -0,0 +1,67 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kanban-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "kanban-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kanban-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kanban-mcp.labels" -}} +helm.sh/chart: {{ include "kanban-mcp.chart" . }} +{{ include "kanban-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kanban-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kanban-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "kanban-mcp.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "kanban-mcp.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the server URL for MCP +*/}} +{{- define "kanban-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "kanban-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} diff --git a/helm/tools/kanban-mcp/templates/configmap.yaml b/helm/tools/kanban-mcp/templates/configmap.yaml new file mode 100644 index 000000000..40abaed73 --- /dev/null +++ b/helm/tools/kanban-mcp/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/helm/tools/kanban-mcp/templates/deployment.yaml b/helm/tools/kanban-mcp/templates/deployment.yaml new file mode 100644 index 000000000..778860834 --- /dev/null +++ b/helm/tools/kanban-mcp/templates/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "kanban-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "kanban-mcp.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "kanban-mcp.serviceAccountName" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: kanban-mcp + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }} + {{- with .Values.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "kanban-mcp.fullname" . }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/tools/kanban-mcp/templates/remotemcpserver.yaml b/helm/tools/kanban-mcp/templates/remotemcpserver.yaml new file mode 100644 index 000000000..2ea6559db --- /dev/null +++ b/helm/tools/kanban-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,20 @@ +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + description: Kanban task board MCP server + protocol: STREAMABLE_HTTP + sseReadTimeout: 5m0s + terminateOnClose: true + timeout: 30s + url: {{ include "kanban-mcp.serverUrl" . }} + ui: + enabled: true + pathPrefix: "kanban" + displayName: "Kanban Board" + icon: "kanban" + section: "AGENTS" diff --git a/helm/tools/kanban-mcp/templates/service.yaml b/helm/tools/kanban-mcp/templates/service.yaml new file mode 100644 index 000000000..44090b822 --- /dev/null +++ b/helm/tools/kanban-mcp/templates/service.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kanban-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "kanban-mcp.selectorLabels" . | nindent 4 }} +--- +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kanban-mcp.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kanban-mcp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/tools/kanban-mcp/values.yaml b/helm/tools/kanban-mcp/values.yaml new file mode 100644 index 000000000..bd1f703f5 --- /dev/null +++ b/helm/tools/kanban-mcp/values.yaml @@ -0,0 +1,46 @@ +replicas: 1 + +image: + registry: localhost:5001 + repository: kagent-dev/kagent/kanban-mcp + pullPolicy: Always + tag: "" + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +securityContext: {} + +tolerations: [] + +nodeSelector: {} + +service: + type: ClusterIP + port: 8080 + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +args: [] + +volumes: [] + +volumeMounts: [] + +config: + KANBAN_ADDR: ":8080" + KANBAN_TRANSPORT: "http" + KANBAN_DB_TYPE: "sqlite" + KANBAN_DB_PATH: "/data/kanban.db" + KANBAN_LOG_LEVEL: "info" diff --git a/helm/tools/nats-activity-feed/templates/_helpers.tpl b/helm/tools/nats-activity-feed/templates/_helpers.tpl new file mode 100644 index 000000000..6341c2155 --- /dev/null +++ b/helm/tools/nats-activity-feed/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "nats-activity-feed.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "nats-activity-feed.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "nats-activity-feed.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "nats-activity-feed.labels" -}} +helm.sh/chart: {{ include "nats-activity-feed.chart" . }} +{{ include "nats-activity-feed.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "nats-activity-feed.selectorLabels" -}} +app.kubernetes.io/name: {{ include "nats-activity-feed.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "nats-activity-feed.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "nats-activity-feed.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/helm/tools/nats-activity-feed/templates/configmap.yaml b/helm/tools/nats-activity-feed/templates/configmap.yaml new file mode 100644 index 000000000..c5fe6f4ee --- /dev/null +++ b/helm/tools/nats-activity-feed/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "nats-activity-feed.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "nats-activity-feed.labels" . | nindent 4 }} +data: + {{- range $key, $val := .Values.config }} + {{ $key }}: {{ $val | quote }} + {{- end }} diff --git a/helm/tools/nats-activity-feed/templates/deployment.yaml b/helm/tools/nats-activity-feed/templates/deployment.yaml new file mode 100644 index 000000000..bc1f2e6c1 --- /dev/null +++ b/helm/tools/nats-activity-feed/templates/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "nats-activity-feed.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "nats-activity-feed.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "nats-activity-feed.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "nats-activity-feed.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "nats-activity-feed.serviceAccountName" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: nats-activity-feed + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "nats-activity-feed.fullname" . }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 2 + periodSeconds: 5 diff --git a/helm/tools/nats-activity-feed/templates/service.yaml b/helm/tools/nats-activity-feed/templates/service.yaml new file mode 100644 index 000000000..3470639fd --- /dev/null +++ b/helm/tools/nats-activity-feed/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "nats-activity-feed.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "nats-activity-feed.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "nats-activity-feed.selectorLabels" . | nindent 4 }} diff --git a/helm/tools/nats-activity-feed/templates/serviceaccount.yaml b/helm/tools/nats-activity-feed/templates/serviceaccount.yaml new file mode 100644 index 000000000..3b197582a --- /dev/null +++ b/helm/tools/nats-activity-feed/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "nats-activity-feed.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "nats-activity-feed.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/tools/nats-activity-feed/values.yaml b/helm/tools/nats-activity-feed/values.yaml new file mode 100644 index 000000000..33173f140 --- /dev/null +++ b/helm/tools/nats-activity-feed/values.yaml @@ -0,0 +1,39 @@ +replicas: 1 + +image: + registry: localhost:5001 + repository: kagent-dev/kagent/nats-activity-feed + pullPolicy: Always + tag: "" + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +securityContext: {} + +tolerations: [] + +nodeSelector: {} + +service: + type: ClusterIP + port: 8090 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + +config: + NATS_ADDR: "nats://nats:4222" + ACTIVITY_FEED_ADDR: ":8090" + ACTIVITY_FEED_BUFFER: "100" + ACTIVITY_FEED_SUBJECT: "agent.>" diff --git a/helm/tools/temporal-mcp/Chart-template.yaml b/helm/tools/temporal-mcp/Chart-template.yaml new file mode 100644 index 000000000..54393efb1 --- /dev/null +++ b/helm/tools/temporal-mcp/Chart-template.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: temporal-mcp +description: MCP server for Temporal workflow administration +type: application +version: ${VERSION} diff --git a/helm/tools/temporal-mcp/templates/_helpers.tpl b/helm/tools/temporal-mcp/templates/_helpers.tpl new file mode 100644 index 000000000..e0e99f969 --- /dev/null +++ b/helm/tools/temporal-mcp/templates/_helpers.tpl @@ -0,0 +1,67 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "temporal-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "temporal-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "temporal-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "temporal-mcp.labels" -}} +helm.sh/chart: {{ include "temporal-mcp.chart" . }} +{{ include "temporal-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "temporal-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "temporal-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "temporal-mcp.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "temporal-mcp.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the server URL for MCP +*/}} +{{- define "temporal-mcp.serverUrl" -}} +{{- printf "http://%s.%s:%d/mcp" (include "temporal-mcp.fullname" .) .Release.Namespace (.Values.service.port | int) }} +{{- end }} diff --git a/helm/tools/temporal-mcp/templates/configmap.yaml b/helm/tools/temporal-mcp/templates/configmap.yaml new file mode 100644 index 000000000..088acf0cf --- /dev/null +++ b/helm/tools/temporal-mcp/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "temporal-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "temporal-mcp.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/helm/tools/temporal-mcp/templates/deployment.yaml b/helm/tools/temporal-mcp/templates/deployment.yaml new file mode 100644 index 000000000..177de62dd --- /dev/null +++ b/helm/tools/temporal-mcp/templates/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "temporal-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "temporal-mcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "temporal-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "temporal-mcp.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "temporal-mcp.serviceAccountName" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: temporal-mcp + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }} + {{- with .Values.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "temporal-mcp.fullname" . }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/tools/temporal-mcp/templates/remotemcpserver.yaml b/helm/tools/temporal-mcp/templates/remotemcpserver.yaml new file mode 100644 index 000000000..483277e3f --- /dev/null +++ b/helm/tools/temporal-mcp/templates/remotemcpserver.yaml @@ -0,0 +1,20 @@ +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: {{ include "temporal-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "temporal-mcp.labels" . | nindent 4 }} +spec: + description: Temporal workflow administration MCP server + protocol: STREAMABLE_HTTP + sseReadTimeout: 5m0s + terminateOnClose: true + timeout: 30s + url: {{ include "temporal-mcp.serverUrl" . }} + ui: + enabled: true + pathPrefix: "temporal-workflows" + displayName: "Temporal Workflows" + icon: "git-branch" + section: "PLUGINS" diff --git a/helm/tools/temporal-mcp/templates/service.yaml b/helm/tools/temporal-mcp/templates/service.yaml new file mode 100644 index 000000000..ff426b12a --- /dev/null +++ b/helm/tools/temporal-mcp/templates/service.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "temporal-mcp.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "temporal-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "temporal-mcp.selectorLabels" . | nindent 4 }} +--- +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "temporal-mcp.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "temporal-mcp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/tools/temporal-mcp/values.yaml b/helm/tools/temporal-mcp/values.yaml new file mode 100644 index 000000000..225c2483c --- /dev/null +++ b/helm/tools/temporal-mcp/values.yaml @@ -0,0 +1,47 @@ +replicas: 1 + +image: + registry: localhost:5001 + repository: kagent-dev/kagent/temporal-mcp + pullPolicy: Always + tag: "" + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +securityContext: {} + +tolerations: [] + +nodeSelector: {} + +service: + type: ClusterIP + port: 8080 + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +args: [] + +volumes: [] + +volumeMounts: [] + +config: + TEMPORAL_ADDR: ":8080" + TEMPORAL_TRANSPORT: "http" + TEMPORAL_HOST_PORT: "temporal-server:7233" + TEMPORAL_NAMESPACE: "kagent" + TEMPORAL_POLL_INTERVAL: "5s" + TEMPORAL_LOG_LEVEL: "info" diff --git a/scripts/check-plugins-api.sh b/scripts/check-plugins-api.sh new file mode 100755 index 000000000..acc0e50d2 --- /dev/null +++ b/scripts/check-plugins-api.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash + +set -euo pipefail + +API_URL="${API_URL:-http://localhost:8080/api/plugins}" +PLUGIN_PATH_PREFIX="${PLUGIN_PATH_PREFIX:-kanban-mcp}" +PLUGIN_SECTION="${PLUGIN_SECTION:-AGENTS}" +CONNECT_TIMEOUT_SECONDS="${CONNECT_TIMEOUT_SECONDS:-5}" +MAX_TIME_SECONDS="${MAX_TIME_SECONDS:-15}" +WAIT=false +WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" +WAIT_INTERVAL="${WAIT_INTERVAL:-5}" +PROXY_CHECK=false +PROXY_BASE_URL="${PROXY_BASE_URL:-http://localhost:8080}" + +usage() { + cat <<'EOF' +Check kagent plugins API and verify expected plugin entry. + +Usage: + scripts/check-plugins-api.sh [OPTIONS] + +Options: + --url Full plugins endpoint URL (default: http://localhost:8080/api/plugins) + --plugin Plugin pathPrefix to validate (default: kanban-mcp) + --section Expected section for plugin (default: AGENTS) + --wait Poll until plugin appears (default: false) + --wait-timeout Max seconds to wait in poll mode (default: 120) + --wait-interval Seconds between poll attempts (default: 5) + --proxy Also verify /_p/{plugin}/ reverse proxy returns non-404 + --proxy-base-url Base URL for proxy check (default: http://localhost:8080) + -h, --help Show help + +Environment overrides: + API_URL, PLUGIN_PATH_PREFIX, PLUGIN_SECTION + CONNECT_TIMEOUT_SECONDS, MAX_TIME_SECONDS + WAIT_TIMEOUT, WAIT_INTERVAL, PROXY_BASE_URL + +Exit codes: + 0 API reachable and expected plugin found (and proxy ok if --proxy) + 1 Validation failed + 2 Missing required runtime dependency +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --url) + API_URL="$2" + shift 2 + ;; + --plugin) + PLUGIN_PATH_PREFIX="$2" + shift 2 + ;; + --section) + PLUGIN_SECTION="$2" + shift 2 + ;; + --wait) + WAIT=true + shift + ;; + --wait-timeout) + WAIT_TIMEOUT="$2" + shift 2 + ;; + --wait-interval) + WAIT_INTERVAL="$2" + shift 2 + ;; + --proxy) + PROXY_CHECK=true + shift + ;; + --proxy-base-url) + PROXY_BASE_URL="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if ! command -v curl >/dev/null 2>&1; then + echo "ERROR: curl is required but not found in PATH." >&2 + exit 2 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required but not found in PATH." >&2 + exit 2 +fi + +tmp_body="$(mktemp)" +trap 'rm -f "$tmp_body"' EXIT + +# check_plugins_api does a single check. Returns 0 on success. +check_plugins_api() { + local http_code + http_code="$( + curl -sS \ + --connect-timeout "$CONNECT_TIMEOUT_SECONDS" \ + --max-time "$MAX_TIME_SECONDS" \ + -w "%{http_code}" \ + -o "$tmp_body" \ + "$API_URL" 2>/dev/null + )" || http_code="000" + + if [[ "$http_code" != "200" ]]; then + echo " HTTP $http_code (expected 200)" + return 1 + fi + + python3 - "$tmp_body" "$PLUGIN_PATH_PREFIX" "$PLUGIN_SECTION" <<'PY' +import json +import sys + +body_path, expected_prefix, expected_section = sys.argv[1:] + +try: + with open(body_path, "r", encoding="utf-8") as f: + payload = json.load(f) +except Exception as exc: + print(f" JSON parse error: {exc}", file=sys.stderr) + sys.exit(1) + +data = payload.get("data") +if not isinstance(data, list): + print(" Response missing 'data' list", file=sys.stderr) + sys.exit(1) + +match = None +for item in data: + if not isinstance(item, dict): + continue + if item.get("pathPrefix") == expected_prefix: + match = item + break + +if match is None: + known = [str(p.get("pathPrefix")) for p in data if isinstance(p, dict)] + print(f" Plugin '{expected_prefix}' not found (have: {', '.join(known) or 'none'})") + sys.exit(1) + +actual_section = match.get("section") +if actual_section != expected_section: + print(f" Section mismatch: expected '{expected_section}', got '{actual_section}'", file=sys.stderr) + sys.exit(1) + +print(f" PASS: plugin '{expected_prefix}' found in section '{expected_section}'") +print(json.dumps(match, indent=2)) +PY +} + +echo "Checking endpoint: $API_URL" +echo "Looking for plugin: $PLUGIN_PATH_PREFIX (section: $PLUGIN_SECTION)" + +if [[ "$WAIT" == "true" ]]; then + echo "Polling mode: timeout=${WAIT_TIMEOUT}s, interval=${WAIT_INTERVAL}s" + elapsed=0 + while (( elapsed < WAIT_TIMEOUT )); do + if check_plugins_api; then + break + fi + elapsed=$(( elapsed + WAIT_INTERVAL )) + if (( elapsed >= WAIT_TIMEOUT )); then + echo "ERROR: timed out after ${WAIT_TIMEOUT}s waiting for plugin" >&2 + exit 1 + fi + echo " Retrying in ${WAIT_INTERVAL}s... (${elapsed}/${WAIT_TIMEOUT}s)" + sleep "$WAIT_INTERVAL" + done +else + if ! check_plugins_api; then + echo "ERROR: plugin check failed" >&2 + exit 1 + fi +fi + +# Proxy check: verify /_p/{name}/ returns non-404 +if [[ "$PROXY_CHECK" == "true" ]]; then + proxy_url="${PROXY_BASE_URL}/_p/${PLUGIN_PATH_PREFIX}/" + echo "" + echo "Checking proxy: $proxy_url" + proxy_code="$( + curl -sS \ + --connect-timeout "$CONNECT_TIMEOUT_SECONDS" \ + --max-time "$MAX_TIME_SECONDS" \ + -w "%{http_code}" \ + -o /dev/null \ + "$proxy_url" 2>/dev/null + )" || proxy_code="000" + + if [[ "$proxy_code" == "404" ]]; then + echo "ERROR: proxy returned 404 — plugin routing not configured" >&2 + exit 1 + fi + + echo " PASS: proxy returned HTTP $proxy_code (non-404)" +fi + +echo "" +echo "All checks passed." diff --git a/specs/ROADMAP.md b/specs/ROADMAP.md new file mode 100644 index 000000000..9a0f90d6a --- /dev/null +++ b/specs/ROADMAP.md @@ -0,0 +1,46 @@ +# ROADMAP + +## MAIN IDEAS + +1. Temporal - durable execution +2. NATS - event streaming to a2a / SEE +3. Kanban MCP - task tracking between Agents with context +4. Git MCP - knowledge from Git Repos with context and search capabilities + +## Claws +https://moltis.org/#features + +## Kanban + +Improve | Tasks | Features | Board + +https://github.com/mantoni/beads-ui +https://github.com/steveyegge/beads +https://github.com/AvivK5498/Beads-Kanban-UI +https://github.com/dimetron/automaker/tree/merges-main + +## Git Repos + +### Simple + +https://github.com/BurntSushi/ripgrep +https://github.com/yoanbernabeu/grepai + +```bash +llm install llm-sentence-transformers +llm embed-multi myrepo -m sentence-transformers/all-MiniLM-L6-v2 --files . '**/*.go' +llm similar myrepo -c "where do we set up auth?" +``` + +### Medium +https://github.com/reflex-search/reflex + +### Advanced +https://code-graph-rag.com/features +https://github.com/FalkorDB/code-graph +https://github.com/getzep/graphiti/blob/main/mcp_server/README.md +https://github.com/raphaelmansuy/edgequake/tree/edgequake-main/mcp + + + + diff --git a/specs/ai-cron-jobs/PROMPT.md b/specs/ai-cron-jobs/PROMPT.md new file mode 100644 index 000000000..3c5053866 --- /dev/null +++ b/specs/ai-cron-jobs/PROMPT.md @@ -0,0 +1,30 @@ +# AgentCronJob Implementation + +## Objective + +Implement `AgentCronJob` — a Kubernetes CRD (`kagent.dev/v1alpha2`) that schedules AI agent prompt execution on a cron. Minimal MVP: schedule + prompt + agentRef. Controller triggers runs via the existing HTTP server API, results stored in sessions. + +## Key Requirements + +- CRD: `AgentCronJob` with spec fields `schedule` (cron), `prompt` (string), `agentRef` (string) +- Status: `lastRunTime`, `nextRunTime`, `lastRunResult`, `lastRunMessage`, `lastSessionID`, conditions (`Accepted`, `Ready`) +- Controller uses `RequeueAfter` with `robfig/cron/v3` for schedule parsing — no in-memory scheduler +- Execution: POST `/api/sessions` to create session, POST `/api/a2a/{ns}/{name}` to send prompt +- On failure: set status Failed, retry on next tick — no immediate requeue +- On restart: recalculate next run from schedule, do NOT retroactively execute missed runs +- HTTP server: CRUD endpoints at `/api/cronjobs` (proxy to K8s API, same pattern as `/api/agents`) +- UI: replace placeholder at `ui/src/app/cronjobs/page.tsx` with list + create/edit form +- No new database models — reuse existing sessions/tasks/events + +## Acceptance Criteria + +- Given a valid AgentCronJob manifest, when applied, then status shows Accepted=True and nextRunTime populated +- Given a scheduled AgentCronJob, when tick fires, then session is created, prompt sent, status updated with Success + sessionID +- Given an AgentCronJob referencing non-existent agent, when tick fires, then status shows Failed with error message +- Given HTTP server running, when CRUD requests sent to /api/cronjobs, then CRs are created/read/updated/deleted +- Given UI loaded at /cronjobs, then user can list, create, edit, delete cron jobs and see status +- Given controller restarts, then next run recalculated without retroactive execution + +## Reference + +Full specs in `specs/ai-cron-jobs/` — design.md (architecture, types, error handling), plan.md (7 implementation steps), research/ (codebase patterns). diff --git a/specs/ai-cron-jobs/design.md b/specs/ai-cron-jobs/design.md new file mode 100644 index 000000000..49e1b4e5b --- /dev/null +++ b/specs/ai-cron-jobs/design.md @@ -0,0 +1,372 @@ +# AgentCronJob — Detailed Design + +## Overview + +AgentCronJob is a Kubernetes CRD that enables scheduled execution of AI agent prompts on a cron schedule. It references an existing `Agent` CR and sends a static prompt to it at specified intervals via the kagent HTTP server API. Each execution creates a new session, and results are stored using the existing session/task/event infrastructure. + +This is a minimal first implementation: schedule + prompt + agent ref. Advanced CronJob semantics (concurrency policy, suspend, history limits) are deferred to future iterations. + +--- + +## Detailed Requirements + +1. **CRD:** `AgentCronJob` in `kagent.dev/v1alpha2` +2. **Spec fields (minimal):** + - `schedule` — cron expression (standard 5-field format) + - `prompt` — static string sent as user message + - `agentRef` — reference to an existing Agent CR (namespace/name) +3. **Execution:** Controller calls kagent HTTP server API (same path as UI) + - Creates a new session per execution + - Sends prompt via A2A endpoint +4. **Output:** Stored in sessions (existing database models) +5. **Status:** Last run time, success/failure, next scheduled run, last session ID +6. **Error handling:** Set status to failed, retry on next scheduled tick (no immediate requeue) +7. **HTTP server:** Exposes CRUD endpoints for AgentCronJob (`/api/cronjobs`) +8. **UI:** Existing placeholder page at `/cronjobs` to be populated with CRUD operations + +--- + +## Architecture Overview + +```mermaid +graph TB + subgraph "Kubernetes" + ACJ[AgentCronJob CR] + Agent[Agent CR] + ACJC[AgentCronJob Controller] + end + + subgraph "kagent Backend" + HTTP[HTTP Server] + DB[(Database)] + Runtime[Agent Runtime] + end + + subgraph "kagent UI" + UI[CronJobs Page] + end + + ACJ -->|watches| ACJC + ACJC -->|reads| Agent + ACJC -->|"POST /api/sessions"| HTTP + ACJC -->|"POST /api/a2a/{ns}/{name}"| HTTP + ACJC -->|updates status| ACJ + HTTP -->|creates session| DB + HTTP -->|invokes| Runtime + Runtime -->|stores events| DB + UI -->|"CRUD /api/cronjobs"| HTTP + HTTP -->|"reads/writes"| ACJ +``` + +### Execution Flow + +```mermaid +sequenceDiagram + participant Ctrl as AgentCronJob Controller + participant K8s as Kubernetes API + participant HTTP as HTTP Server + participant RT as Agent Runtime + participant DB as Database + + loop Every reconciliation + Ctrl->>K8s: Get AgentCronJob CR + Ctrl->>Ctrl: Check if schedule is due + alt Schedule is due + Ctrl->>K8s: Verify Agent CR exists + Ctrl->>HTTP: POST /api/sessions (agent_ref, name) + HTTP->>DB: Store session + HTTP-->>Ctrl: Session ID + Ctrl->>HTTP: POST /api/a2a/{ns}/{name} (prompt, contextID) + HTTP->>RT: Invoke agent with prompt + RT->>DB: Store task, events + HTTP-->>Ctrl: Success/failure + Ctrl->>K8s: Update status (lastRunTime, sessionID, success) + end + Ctrl->>Ctrl: Calculate next run time + Ctrl->>K8s: Update status (nextRunTime) + Ctrl-->>Ctrl: RequeueAfter(duration to next run) + end +``` + +--- + +## Components and Interfaces + +### 1. CRD Type Definition + +**File:** `go/api/v1alpha2/agentcronjob_types.go` + +```go +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Schedule",type="string",JSONPath=".spec.schedule" +// +kubebuilder:printcolumn:name="Agent",type="string",JSONPath=".spec.agentRef" +// +kubebuilder:printcolumn:name="LastRun",type="date",JSONPath=".status.lastRunTime" +// +kubebuilder:printcolumn:name="NextRun",type="date",JSONPath=".status.nextRunTime" +// +kubebuilder:printcolumn:name="LastResult",type="string",JSONPath=".status.lastRunResult" +type AgentCronJob struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec AgentCronJobSpec `json:"spec,omitempty"` + Status AgentCronJobStatus `json:"status,omitempty"` +} + +type AgentCronJobSpec struct { + // Schedule in standard cron format (5-field: minute hour day month weekday) + // +kubebuilder:validation:MinLength=1 + Schedule string `json:"schedule"` + + // Prompt is the static user message sent to the agent on each run + // +kubebuilder:validation:MinLength=1 + Prompt string `json:"prompt"` + + // AgentRef is a reference to the Agent CR (format: "namespace/name" or just "name" for same namespace) + // +kubebuilder:validation:MinLength=1 + AgentRef string `json:"agentRef"` +} + +type AgentCronJobStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // LastRunTime is the timestamp of the most recent execution + // +optional + LastRunTime *metav1.Time `json:"lastRunTime,omitempty"` + + // NextRunTime is the calculated timestamp of the next execution + // +optional + NextRunTime *metav1.Time `json:"nextRunTime,omitempty"` + + // LastRunResult is the result of the most recent execution: "Success" or "Failed" + // +optional + LastRunResult string `json:"lastRunResult,omitempty"` + + // LastRunMessage contains error details when LastRunResult is "Failed" + // +optional + LastRunMessage string `json:"lastRunMessage,omitempty"` + + // LastSessionID is the session ID created by the most recent execution + // +optional + LastSessionID string `json:"lastSessionID,omitempty"` +} + +// +kubebuilder:object:root=true +type AgentCronJobList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AgentCronJob `json:"items"` +} +``` + +### 2. Controller + +**File:** `go/internal/controller/agentcronjob_controller.go` + +**Scheduling approach:** Use `RequeueAfter` with the duration until the next scheduled run. On each reconciliation: +1. Parse the cron schedule +2. Check if current time >= next run time +3. If due: execute the prompt, update status +4. Calculate next run time from cron schedule +5. Return `RequeueAfter(nextRunTime - now)` + +**Why RequeueAfter over an in-memory cron library:** +- Simpler — no additional dependency +- Survives controller restarts (schedule recalculated from CRD) +- Consistent with K8s controller patterns +- Adequate precision for cron-scale scheduling (minute granularity) + +**Dependencies:** +- `github.com/robfig/cron/v3` — for parsing cron expressions and calculating next run times (standard Go cron library, widely used) +- K8s client for reading Agent CRs +- HTTP client for calling kagent API + +**RBAC markers:** +```go +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs/finalizers,verbs=update +// +kubebuilder:rbac:groups=kagent.dev,resources=agents,verbs=get;list;watch +``` + +### 3. HTTP Server Endpoints + +**File:** `go/internal/httpserver/handlers/cronjobs.go` + +CRUD endpoints that proxy to Kubernetes API (same pattern as agents): + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/cronjobs` | List all AgentCronJobs | +| GET | `/api/cronjobs/{namespace}/{name}` | Get single AgentCronJob | +| POST | `/api/cronjobs` | Create AgentCronJob | +| PUT | `/api/cronjobs/{namespace}/{name}` | Update AgentCronJob | +| DELETE | `/api/cronjobs/{namespace}/{name}` | Delete AgentCronJob | + +Request/response format follows existing patterns: +```go +type StandardResponse[T any] struct { + Error bool `json:"error"` + Data T `json:"data,omitempty"` + Message string `json:"message,omitempty"` +} +``` + +### 4. UI Components + +**Files:** +- `ui/src/app/cronjobs/page.tsx` — list page (replace placeholder) +- `ui/src/app/cronjobs/new/page.tsx` — create/edit form +- `ui/src/app/actions/cronjobs.ts` — server actions +- `ui/src/types/index.ts` — type additions + +**TypeScript types:** +```typescript +export interface AgentCronJob { + metadata: ResourceMetadata; + spec: AgentCronJobSpec; + status?: AgentCronJobStatus; +} + +export interface AgentCronJobSpec { + schedule: string; + prompt: string; + agentRef: string; +} + +export interface AgentCronJobStatus { + lastRunTime?: string; + nextRunTime?: string; + lastRunResult?: string; + lastRunMessage?: string; + lastSessionID?: string; +} +``` + +**UI pattern:** Follow Models page pattern — table with expandable rows showing prompt text and status details. Columns: Name, Schedule, Agent, Last Run, Next Run, Status, Actions (edit/delete). + +--- + +## Data Models + +No new database models required. The execution flow reuses existing models: + +- **Session** — one created per cron execution, named `"cronjob-{cronjob-name}-{timestamp}"` +- **Task** — created by A2A invocation, linked to session +- **Event** — agent messages stored as events in session + +The CRD status itself tracks execution metadata (last run, session ID, etc.) — this lives in Kubernetes, not the database. + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Agent CR not found | Set status `Failed`, message "Agent not found", wait for next tick | +| HTTP server unreachable | Set status `Failed`, message with error, wait for next tick | +| A2A invocation fails | Set status `Failed`, message with error, session may be partially created | +| Invalid cron expression | Set condition `Accepted=False`, do not schedule | +| Controller restart | Recalculate next run from cron schedule + last run time; do NOT retroactively run missed executions | + +**Status condition types:** +- `Accepted` — cron expression is valid and agent ref is resolvable +- `Ready` — controller is actively scheduling runs + +--- + +## Acceptance Criteria + +### AC1: CRD Creation +- **Given** a valid AgentCronJob manifest with schedule, prompt, and agentRef +- **When** applied to the cluster +- **Then** the CRD is created, status shows `Accepted=True`, and `nextRunTime` is populated + +### AC2: Scheduled Execution +- **Given** an AgentCronJob with schedule `"*/5 * * * *"` and a valid agent ref +- **When** the scheduled time arrives +- **Then** a new session is created, the prompt is sent to the agent, and status updates with `lastRunTime`, `lastSessionID`, and `lastRunResult=Success` + +### AC3: Failed Execution +- **Given** an AgentCronJob referencing a non-existent agent +- **When** the scheduled time arrives +- **Then** status shows `lastRunResult=Failed` with error message, and the next execution still fires on schedule + +### AC4: CRUD via HTTP API +- **Given** the HTTP server is running +- **When** a client sends GET/POST/PUT/DELETE to `/api/cronjobs` +- **Then** the corresponding AgentCronJob CR is listed/created/updated/deleted in Kubernetes + +### AC5: UI CRUD +- **Given** the UI is loaded +- **When** user navigates to `/cronjobs` +- **Then** they can list, create, edit, and delete AgentCronJobs, and see status (last run, next run, result) + +### AC6: Session Visibility +- **Given** a cron job has executed successfully +- **When** user clicks the session link in the cron job status +- **Then** they can view the full agent conversation from that execution + +### AC7: Controller Restart Recovery +- **Given** an AgentCronJob was scheduled and the controller restarts +- **When** the controller comes back up +- **Then** it recalculates the next run time without retroactively executing missed runs + +--- + +## Testing Strategy + +### Unit Tests +- Cron expression parsing and next-run calculation +- Reconciliation logic (schedule due, not due, error cases) +- HTTP handler request/response serialization +- Status update logic + +### Integration Tests +- Controller reconciliation with mock HTTP client +- HTTP server CRUD endpoints with test K8s API + +### E2E Tests +- Create AgentCronJob → verify status populated +- Wait for execution → verify session created with prompt +- Delete AgentCronJob → verify cleanup +- Invalid agent ref → verify failed status + +--- + +## Appendices + +### A. Technology Choices + +| Choice | Rationale | +|--------|-----------| +| `robfig/cron/v3` | Standard Go cron library, well-tested, supports 5-field cron expressions | +| `RequeueAfter` scheduling | No extra dependency beyond cron parser, survives restarts, K8s-native pattern | +| HTTP API for execution | Reuses existing session/A2A infrastructure, same code path as UI | +| No new DB models | Sessions/tasks/events already exist, cron metadata lives in CRD status | + +### B. Alternative Approaches Considered + +1. **Kubernetes CronJob spawning pods** — rejected: heavyweight, each run would need a container image, doesn't reuse existing agent runtime +2. **In-memory cron scheduler (goroutine)** — rejected: doesn't survive restarts without persistence, adds complexity vs RequeueAfter +3. **Database-backed scheduler** — rejected: duplicates state that belongs in the CRD, adds migration burden +4. **Direct agent runtime invocation** — rejected: bypasses session tracking, inconsistent with UI flow + +### C. Research References + +- CRD patterns: `go/api/v1alpha2/agent_types.go` +- Controller registration: `go/pkg/app/app.go` +- Shared reconciler: `go/internal/controller/reconciler/reconciler.go` +- HTTP handlers: `go/internal/httpserver/handlers/sessions.go` +- A2A protocol: `go/internal/httpserver/handlers/a2a.go` +- Database models: `go/pkg/database/models.go` +- UI placeholder: `ui/src/app/cronjobs/page.tsx` +- UI CRUD pattern: `ui/src/app/models/page.tsx` + +### D. Future Enhancements (Out of Scope) +- Concurrency policy (Allow/Forbid/Replace) +- Suspend/resume +- History limits (max sessions to retain) +- Prompt templating with variables (date, namespace, etc.) +- Execution timeout +- Webhook notifications on completion/failure diff --git a/specs/ai-cron-jobs/plan.md b/specs/ai-cron-jobs/plan.md new file mode 100644 index 000000000..755133820 --- /dev/null +++ b/specs/ai-cron-jobs/plan.md @@ -0,0 +1,242 @@ +# AgentCronJob — Implementation Plan + +## Checklist + +- [ ] Step 1: CRD type definition and code generation +- [ ] Step 2: Controller with scheduling logic +- [ ] Step 3: HTTP server CRUD endpoints +- [ ] Step 4: UI list page and server actions +- [ ] Step 5: UI create/edit form +- [ ] Step 6: E2E tests +- [ ] Step 7: Helm chart and RBAC updates + +--- + +## Step 1: CRD Type Definition and Code Generation + +**Objective:** Define the `AgentCronJob` CRD types and generate deepcopy/CRD manifests. + +**Implementation guidance:** +- Create `go/api/v1alpha2/agentcronjob_types.go` with `AgentCronJob`, `AgentCronJobSpec`, `AgentCronJobStatus`, `AgentCronJobList` +- Add kubebuilder markers: `+kubebuilder:object:root=true`, `+subresource:status`, `+storageversion`, `+printcolumn` for Schedule, Agent, LastRun, NextRun, LastResult +- Register types in `go/api/v1alpha2/groupversion_info.go` via `init()` — add `&AgentCronJob{}` and `&AgentCronJobList{}` to `SchemeBuilder.Register()` +- Run `make -C go generate` to produce `zz_generated.deepcopy.go` entries and CRD YAML + +**Test requirements:** +- Verify `make -C go generate` succeeds without errors +- Verify CRD YAML is generated in `config/crd/bases/` +- Apply CRD to a test cluster: `kubectl apply -f config/crd/bases/kagent.dev_agentcronjobs.yaml` +- Create a sample CR and verify it's accepted: `kubectl apply` + `kubectl get agentcronjobs` + +**Integration notes:** +- CRD YAML needs to be added to `helm/kagent-crds/` chart templates +- Sample manifest: `examples/agentcronjob.yaml` + +**Demo:** `kubectl apply` a sample AgentCronJob, `kubectl get agentcronjobs` shows the resource with print columns. + +--- + +## Step 2: Controller with Scheduling Logic + +**Objective:** Implement the controller that watches AgentCronJob CRs, calculates schedules, and triggers agent runs via the HTTP API. + +**Implementation guidance:** +- Add `github.com/robfig/cron/v3` dependency: `go get github.com/robfig/cron/v3` +- Create `go/internal/controller/agentcronjob_controller.go`: + - `AgentCronJobReconciler` struct with `client.Client`, `Scheme`, HTTP base URL, HTTP client + - RBAC markers for `agentcronjobs`, `agentcronjobs/status`, `agents` (get/list/watch) + - `SetupWithManager`: watch `AgentCronJob` with `GenerationChangedPredicate` + - `Reconcile` logic: + 1. Fetch AgentCronJob CR + 2. Parse cron schedule with `cron.ParseStandard(spec.Schedule)` — if invalid, set `Accepted=False` condition, return no requeue + 3. Set `Accepted=True` condition + 4. Calculate next run time from schedule. If `status.lastRunTime` is nil, use CR creation time as reference + 5. If `now >= nextRunTime`: execute (create session, send prompt via HTTP), update status fields + 6. Calculate next run from `now`, set `status.nextRunTime`, return `RequeueAfter(nextRun - now)` +- Execution helper (private method): + 1. `POST /api/sessions` with `agent_ref` = spec.agentRef, `name` = `"cronjob-{name}-{timestamp}"` + 2. `POST /api/a2a/{namespace}/{agentName}` with JSON-RPC message containing spec.prompt and session contextID + 3. Use synchronous `message/send` method (not streaming) for simplicity — controller just needs success/failure + 4. Return session ID and error +- Register controller in `go/pkg/app/app.go` following existing pattern — inject HTTP base URL from config +- Handle controller restart: if `lastRunTime` exists and `nextRunTime` is in the past, skip to the next future occurrence (no retroactive runs) + +**Test requirements:** +- Unit test: cron parsing and next-run calculation (table-driven) +- Unit test: reconcile logic with mock HTTP client — schedule due, not due, agent missing, API failure +- Unit test: status update correctness (lastRunTime, nextRunTime, sessionID, result) +- Unit test: controller restart recovery (missed runs not retroactively executed) + +**Integration notes:** +- Controller needs HTTP base URL config (e.g., `http://kagent-controller.kagent.svc:8080`) +- User ID for API calls: use a system user like `"system:cronjob@kagent.dev"` + +**Demo:** Apply an AgentCronJob with `"*/2 * * * *"` schedule. Observe status updates every 2 minutes: `kubectl get agentcronjob -w`. Verify sessions appear in database. + +--- + +## Step 3: HTTP Server CRUD Endpoints + +**Objective:** Add REST endpoints so the UI (and kubectl proxy users) can manage AgentCronJob CRs. + +**Implementation guidance:** +- Create `go/internal/httpserver/handlers/cronjobs.go`: + - `CronJobHandler` struct with K8s `client.Client` + - `HandleListCronJobs` — GET `/api/cronjobs` → `client.List(ctx, &v1alpha2.AgentCronJobList{}, ...)` + - `HandleGetCronJob` — GET `/api/cronjobs/{namespace}/{name}` → `client.Get(ctx, ...)` + - `HandleCreateCronJob` — POST `/api/cronjobs` → decode body, `client.Create(ctx, ...)` + - `HandleUpdateCronJob` — PUT `/api/cronjobs/{namespace}/{name}` → decode body, `client.Update(ctx, ...)` + - `HandleDeleteCronJob` — DELETE `/api/cronjobs/{namespace}/{name}` → `client.Delete(ctx, ...)` +- Register routes in `go/internal/httpserver/server.go` — add to router with auth middleware +- Response format: `StandardResponse[T]` (same as agents) +- Create request body mirrors CRD spec with metadata: + ```go + type CronJobRequest struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Schedule string `json:"schedule"` + Prompt string `json:"prompt"` + AgentRef string `json:"agentRef"` + } + ``` + +**Test requirements:** +- Unit test each handler with mock K8s client (create, get, list, update, delete) +- Test error cases: not found, invalid input, conflict +- Test auth middleware is applied + +**Integration notes:** +- Follows exact same pattern as existing agent handlers +- Auth middleware reuses existing `AuthnMiddleware` + +**Demo:** `curl` the endpoints to create, list, and delete an AgentCronJob. Verify CR appears in `kubectl get agentcronjobs`. + +--- + +## Step 4: UI List Page and Server Actions + +**Objective:** Replace the "Coming soon" placeholder with a functional cron jobs list page. + +**Implementation guidance:** +- Add TypeScript types to `ui/src/types/index.ts`: + - `AgentCronJob`, `AgentCronJobSpec`, `AgentCronJobStatus` interfaces +- Create server actions `ui/src/app/actions/cronjobs.ts`: + - `getCronJobs()` → `fetchApi>("/cronjobs")` + - `getCronJob(namespace, name)` → `fetchApi("/cronjobs/{ns}/{name}")` + - `deleteCronJob(namespace, name)` → `fetchApi("/cronjobs/{ns}/{name}", { method: "DELETE" })` +- Replace `ui/src/app/cronjobs/page.tsx` with list component: + - Fetch cron jobs on mount via server action + - Table layout with columns: Name, Schedule, Agent, Last Run, Next Run, Status + - Expandable rows showing: prompt text, last run message, session link + - Create button → `/cronjobs/new` + - Edit button → `/cronjobs/new?edit=true&name=X&namespace=Y` + - Delete button with confirmation dialog + - Loading/error/empty states + - Toast notifications for actions + +**Test requirements:** +- Verify page renders with mock data +- Verify CRUD actions call correct API endpoints +- Verify error states display properly + +**Integration notes:** +- Session link in expanded row: link to `/agents/{namespace}/{agentName}/chat?session={sessionID}` (or wherever sessions are viewable) +- Status badge: green for Success, red for Failed, gray for Pending + +**Demo:** Navigate to `/cronjobs`, see list of cron jobs with status. Delete one, see toast confirmation. + +--- + +## Step 5: UI Create/Edit Form + +**Objective:** Add a form page for creating and editing AgentCronJob resources. + +**Implementation guidance:** +- Create server actions in `ui/src/app/actions/cronjobs.ts`: + - `createCronJob(data)` → `fetchApi("/cronjobs", { method: "POST", body })` + - `updateCronJob(namespace, name, data)` → `fetchApi("/cronjobs/{ns}/{name}", { method: "PUT", body })` +- Create `ui/src/app/cronjobs/new/page.tsx`: + - Form fields: + - Name (text input, disabled in edit mode) + - Namespace (text input or dropdown, disabled in edit mode) + - Schedule (text input with cron expression, helper text showing human-readable translation) + - Agent (dropdown populated from `GET /api/agents`) + - Prompt (textarea, multi-line) + - Edit mode: read query params `?edit=true&name=X&namespace=Y`, fetch existing CronJob, pre-populate form + - Validation: all fields required, basic cron format check + - Submit: call create or update action, redirect to `/cronjobs` on success + - Cancel: navigate back to `/cronjobs` + +**Test requirements:** +- Verify form renders in create and edit modes +- Verify validation prevents empty fields +- Verify submit calls correct action (create vs update) + +**Integration notes:** +- Agent dropdown reuses existing agent list fetching +- Consider adding a "cron expression helper" — show next 3 run times as preview + +**Demo:** Click "Create", fill out form with `*/5 * * * *` schedule, select agent, enter prompt, submit. See new cron job in list. + +--- + +## Step 6: E2E Tests + +**Objective:** Verify the full flow from CRD creation to scheduled execution. + +**Implementation guidance:** +- Add tests in `go/test/e2e/agentcronjob_test.go`: + - **Test: Create and verify status** — apply AgentCronJob, verify `Accepted=True` and `nextRunTime` is set + - **Test: Scheduled execution** — use a very short schedule (`*/1 * * * *`), wait for execution, verify `lastRunTime` and `lastSessionID` are populated, verify session exists via API + - **Test: Invalid agent ref** — apply AgentCronJob with non-existent agent, wait for scheduled time, verify `lastRunResult=Failed` + - **Test: CRUD via API** — create/read/update/delete via HTTP endpoints, verify K8s state matches + - **Test: Delete cleanup** — delete AgentCronJob, verify controller stops scheduling + +**Test requirements:** +- Use existing E2E test framework and helpers from `go/test/e2e/` +- Tests require Kind cluster with kagent deployed +- Use a test agent (mock or simple echo agent) + +**Integration notes:** +- E2E tests may need a longer timeout for cron-based tests (at least 2 minutes for `*/1` schedule) +- Consider using a mock HTTP server or test agent that responds immediately + +**Demo:** `make -C go test-e2e` passes with new AgentCronJob tests. + +--- + +## Step 7: Helm Chart and RBAC Updates + +**Objective:** Package the CRD and controller RBAC for deployment. + +**Implementation guidance:** +- Add CRD YAML to `helm/kagent-crds/templates/`: + - Copy generated `config/crd/bases/kagent.dev_agentcronjobs.yaml` into chart +- Update `helm/kagent/templates/` RBAC: + - Run `make -C go generate` to regenerate RBAC from markers + - Verify ClusterRole includes agentcronjobs permissions +- Add sample values if any controller config is needed (e.g., HTTP base URL is likely already configured) +- Create example manifest `examples/agentcronjob.yaml`: + ```yaml + apiVersion: kagent.dev/v1alpha2 + kind: AgentCronJob + metadata: + name: daily-cluster-check + namespace: default + spec: + schedule: "0 9 * * *" + agentRef: "default/k8s-agent" + prompt: "Check the health of all pods in the cluster and report any issues." + ``` + +**Test requirements:** +- `helm lint helm/kagent-crds` passes +- `helm lint helm/kagent` passes +- `helm template test helm/kagent-crds` includes CRD +- Deploy to Kind cluster and verify CRD is available + +**Integration notes:** +- CRD chart must be installed before main chart (existing pattern) +- No new Helm values needed for minimal implementation + +**Demo:** `make helm-install` deploys kagent with AgentCronJob support. Apply example manifest, see it in UI. diff --git a/specs/ai-cron-jobs/requirements.md b/specs/ai-cron-jobs/requirements.md new file mode 100644 index 000000000..97f269c3a --- /dev/null +++ b/specs/ai-cron-jobs/requirements.md @@ -0,0 +1,54 @@ +# Requirements + +## Questions & Answers + +### Q1: What is the core concept — should a user be able to define a cron schedule that triggers an AI agent (kagent) to run a prompt at specified intervals, similar to how Kubernetes CronJobs run containers on a schedule? + +**A1:** Yes, exactly — a CRD that schedules prompt execution on a cron. + +### Q2: Which existing kagent resource should the AI CronJob reference — should it point to an existing `Agent` CR (v1alpha2) so the scheduled prompt runs against a fully configured agent with its model, tools, and system prompt? + +**A2:** Yes, it should reference an existing Agent CR. + +### Q3: What should happen with the output of each scheduled run? Options to consider: +- Store results in the CRD status (simple, but limited size) +- Create a child "Run" CR per execution with results (auditable history) +- Write to an external sink (ConfigMap, Secret, webhook, S3, etc.) +- Just log it (simplest, but hard to query) + +Which approach, or combination, makes sense for your use case? + +**A3:** It will be stored in a session, same as current agent runs. Each scheduled execution creates a new session in the existing database. + +### Q4: Should the CRD support standard Kubernetes CronJob semantics like concurrency policy (Allow / Forbid / Replace), suspend, starting deadline seconds, and history limits — or should we start with a minimal spec (just schedule + prompt + agent ref) and add those later? + +**A4:** Start minimal — just schedule, prompt, and agent ref. Advanced CronJob semantics can be added later. + +### Q5: How should the controller trigger the agent run — should it call the existing kagent HTTP server API (the same endpoint the UI uses to create a session and send a message), or should it invoke the agent runtime directly? + +**A5:** Through the HTTP server API, same as the UI uses. + +### Q6: Should the CRD status track execution history — e.g., last run time, last result (success/failure), next scheduled run, and session ID of the most recent execution? + +**A6:** Yes — track last run time, success/failure, next scheduled run, and session ID of the most recent execution. + +### Q7: Should the UI have visibility into AI CronJobs — e.g., a page to list scheduled jobs, see their status, view past sessions triggered by them — or is this purely a CRD/kubectl-managed feature for now? + +**A7:** Yes, there is already a placeholder page in the UI for CRUD operations on AI CronJobs. + +### Q8: What's the CRD name — `AgentCronJob`, `ScheduledTask`, `AgentSchedule`, or something else? And should it live in the `kagent.dev` API group under `v1alpha2`? + +**A8:** `AgentCronJob` in the `kagent.dev` API group, `v1alpha2`. + +### Q9: Should the controller handle error scenarios like the referenced Agent CR not existing, or the HTTP server being unreachable? For example: set status to failed with an error message and retry on the next scheduled tick, or requeue with backoff? + +**A9:** Set status to failed with an error message and retry on the next scheduled tick — no immediate requeue/backoff. + +### Q10: Does the prompt field need to support templating or variable substitution (e.g., injecting current date/time, namespace, or other dynamic values), or is it a static string for now? + +**A10:** Static string for now. Templating can be added later. + +### Q11: Should the HTTP server expose new endpoints for managing AgentCronJobs (list, create, update, delete) — to back the existing UI placeholder — or should the UI talk to the K8s API directly for CRUD and only the scheduled execution goes through the HTTP server? + +**A11:** HTTP server should expose CRUD endpoints for AgentCronJobs (list, create, update, delete) to back the UI. + diff --git a/specs/ai-cron-jobs/research/controller-patterns.md b/specs/ai-cron-jobs/research/controller-patterns.md new file mode 100644 index 000000000..9766726a2 --- /dev/null +++ b/specs/ai-cron-jobs/research/controller-patterns.md @@ -0,0 +1,36 @@ +# Controller Patterns + +## Existing Controllers (6 total) +All use shared reconciler pattern via `KagentReconciler` interface. + +| Controller | CRD | DB Interaction | +|------------|-----|----------------| +| AgentController | Agent | StoreAgent() | +| ModelConfigController | ModelConfig | None | +| ModelProviderConfigController | ModelProviderConfig | None | +| RemoteMCPServerController | RemoteMCPServer | StoreToolServer(), RefreshToolsForServer() | +| ServiceController | corev1.Service | StoreToolServer(), RefreshToolsForServer() | +| MCPServerToolController | MCPServer (kmcp) | StoreToolServer(), RefreshToolsForServer() | + +## Registration Pattern (go/pkg/app/app.go) +```go +rcnclr := reconciler.NewKagentReconciler(apiTranslator, client, dbClient, ...) + +if err := (&controller.YourController{ + Scheme: mgr.GetScheme(), + Reconciler: rcnclr, +}).SetupWithManager(mgr); err != nil { ... } +``` + +## RBAC Markers +```go +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=kagent.dev,resources=agentcronjobs/finalizers,verbs=update +``` + +## Note for AgentCronJob +Unlike other controllers, AgentCronJob needs a **timer/scheduler** mechanism — not just event-driven reconciliation. Options: +- RequeueAfter with calculated next-run duration +- In-memory cron scheduler (e.g., robfig/cron) +- Periodic reconciliation checking schedule vs current time diff --git a/specs/ai-cron-jobs/research/crd-types-patterns.md b/specs/ai-cron-jobs/research/crd-types-patterns.md new file mode 100644 index 000000000..fc44afcba --- /dev/null +++ b/specs/ai-cron-jobs/research/crd-types-patterns.md @@ -0,0 +1,39 @@ +# CRD Types & Patterns (v1alpha2) + +## Existing CRDs +- `Agent` / `AgentList` — agent definition (Declarative or BYO) +- `ModelConfig` / `ModelConfigList` — LLM model configuration +- `ModelProviderConfig` / `ModelProviderConfigList` — provider endpoints +- `RemoteMCPServer` / `RemoteMCPServerList` — remote MCP server references + +## Cross-Resource Reference Pattern +```go +type TypedLocalReference struct { + Kind string `json:"kind"` + ApiGroup string `json:"apiGroup"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} +``` +Used by Agent's Tool references. For simpler cases (same namespace), a plain string ref is used (e.g., `ModelConfig string`). + +## Status Pattern +All CRDs use: +```go +type SomeStatus struct { + ObservedGeneration int64 `json:"observedGeneration"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + // Additional fields as needed +} +``` +Common condition types: `Ready`, `Accepted`. + +## Kubebuilder Markers +- `+kubebuilder:object:root=true` — root type +- `+kubebuilder:subresource:status` — status subresource +- `+kubebuilder:storageversion` — storage version +- `+kubebuilder:printcolumn` — kubectl column display +- `+kubebuilder:validation:Enum`, `Required`, `XValidation` — validation + +## Codegen +`make -C go generate` runs controller-gen for deepcopy and CRD manifests. diff --git a/specs/ai-cron-jobs/research/database-models.md b/specs/ai-cron-jobs/research/database-models.md new file mode 100644 index 000000000..df4558633 --- /dev/null +++ b/specs/ai-cron-jobs/research/database-models.md @@ -0,0 +1,54 @@ +# Database Models & Sessions + +## Session Model +```go +type Session struct { + ID string // Primary key + Name *string // Optional name + UserID string // Primary key (multi-tenant) + AgentID *string // FK to Agent + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt // Soft delete +} +``` + +## Task Model (represents a run) +```go +type Task struct { + ID string + SessionID string // FK to Session + Data string // Serialized protocol.Task (JSON) + ... +} +``` + +## Event Model (messages within session) +```go +type Event struct { + ID string + SessionID string + UserID string + Data string // Serialized protocol.Message (JSON) + ... +} +``` + +## Key Interface Methods +- `StoreSession(session *Session) error` +- `ListSessionsForAgent(agentID, userID string) ([]Session, error)` +- `StoreTask(task *protocol.Task) error` +- `ListTasksForSession(sessionID string) ([]*protocol.Task, error)` + +## Migration: GORM AutoMigrate +All models registered in `manager.go` Initialize(). + +## Upsert Pattern +```go +db.Clauses(clause.OnConflict{UpdateAll: true}).Create(model) +``` + +## For AgentCronJob +- Each cron execution creates a new Session + sends a message via A2A API +- No new DB models needed — sessions/tasks/events reuse existing models +- CRD status stores session ID for reference diff --git a/specs/ai-cron-jobs/research/http-server-api.md b/specs/ai-cron-jobs/research/http-server-api.md new file mode 100644 index 000000000..7e84fa396 --- /dev/null +++ b/specs/ai-cron-jobs/research/http-server-api.md @@ -0,0 +1,43 @@ +# HTTP Server API — Session & Agent Invocation + +## Key Endpoints + +### Session Creation +- **POST `/api/sessions`** +- Body: `{ "agent_ref": "namespace/agent-name", "name": "optional-name" }` +- Returns: `Session { id, name, user_id, agent_id, created_at }` +- Status: 201 Created + +### Agent Invocation (A2A Protocol) +- **POST `/api/a2a/{namespace}/{name}`** +- JSON-RPC 2.0 with SSE streaming +- Body: +```json +{ + "jsonrpc": "2.0", + "method": "message/stream", + "params": { + "message": { + "kind": "message", + "role": "user", + "parts": [{"kind": "text", "text": "prompt here"}], + "contextID": "session-id" + } + }, + "id": "unique-request-id" +} +``` + +### Auth +- `user_id` query param or `X-User-Id` header +- Default: `admin@kagent.dev` + +## Existing CRUD Patterns +- Agents: GET/POST `/api/agents`, GET/PUT/DELETE `/api/agents/{ns}/{name}` +- Sessions: GET/POST `/api/sessions`, GET/PUT/DELETE `/api/sessions/{id}` +- Tasks: POST `/api/tasks`, GET/DELETE `/api/tasks/{id}` + +## For AgentCronJob Controller +1. Create session: POST `/api/sessions` with agent_ref +2. Send prompt: POST `/api/a2a/{ns}/{name}` with contextID = session ID +3. Track session ID in CRD status diff --git a/specs/ai-cron-jobs/research/ui-placeholder.md b/specs/ai-cron-jobs/research/ui-placeholder.md new file mode 100644 index 000000000..6462b5a4c --- /dev/null +++ b/specs/ai-cron-jobs/research/ui-placeholder.md @@ -0,0 +1,45 @@ +# UI Cron Jobs Placeholder + +## Current State +`ui/src/app/cronjobs/page.tsx` — minimal "Coming soon" placeholder with Clock icon. + +Navigation already wired: sidebar has "Cron Jobs → /cronjobs" link. + +## CRUD Page Patterns in Codebase + +### Models Page (best fit for cron jobs) +- Inline state management, expandable rows +- Edit via `/models/new?edit=true&name=X&namespace=Y` +- Delete with confirmation dialog + toast + +### Agent Page +- Delegates to `AgentList` component +- Card grid layout +- Uses context provider for state + +## API Client Pattern +Server Actions in `ui/src/app/actions/`: +```typescript +export async function fetchApi(path: string, options?): Promise +``` +- All requests include `user_id` query param +- Returns `BaseResponse { message, data?, error? }` + +## Types Pattern (ui/src/types/index.ts) +```typescript +export interface ResourceMetadata { name: string; namespace?: string; } +``` + +## Expected Backend Endpoints +``` +GET /api/cronjobs +GET /api/cronjobs/{namespace}/{name} +POST /api/cronjobs +PUT /api/cronjobs/{namespace}/{name} +DELETE /api/cronjobs/{namespace}/{name} +``` + +## Components Used +Shadcn/UI: Button, Card, Dialog, Table, Badge, Input, ScrollArea, Tooltip +Icons: lucide-react (Clock, Plus, Pencil, Trash2, etc.) +Notifications: sonner toast diff --git a/specs/ai-cron-jobs/rough-idea.md b/specs/ai-cron-jobs/rough-idea.md new file mode 100644 index 000000000..aaf8cc227 --- /dev/null +++ b/specs/ai-cron-jobs/rough-idea.md @@ -0,0 +1,3 @@ +# Rough Idea + +AI Cron Jobs with prompt diff --git a/specs/ai-cron-jobs/summary.md b/specs/ai-cron-jobs/summary.md new file mode 100644 index 000000000..b48e38242 --- /dev/null +++ b/specs/ai-cron-jobs/summary.md @@ -0,0 +1,32 @@ +# AgentCronJob — Project Summary + +## Artifacts + +| File | Description | +|------|-------------| +| `specs/ai-cron-jobs/rough-idea.md` | Original concept | +| `specs/ai-cron-jobs/requirements.md` | 11 Q&A pairs defining scope and constraints | +| `specs/ai-cron-jobs/research/crd-types-patterns.md` | Existing v1alpha2 CRD patterns | +| `specs/ai-cron-jobs/research/http-server-api.md` | Session creation and A2A invocation flow | +| `specs/ai-cron-jobs/research/controller-patterns.md` | Shared reconciler and controller registration | +| `specs/ai-cron-jobs/research/database-models.md` | Session/task/event models (no new models needed) | +| `specs/ai-cron-jobs/research/ui-placeholder.md` | Existing UI placeholder and CRUD patterns | +| `specs/ai-cron-jobs/design.md` | Full design: CRD types, controller, HTTP API, UI, error handling, acceptance criteria | +| `specs/ai-cron-jobs/plan.md` | 7-step incremental implementation plan | + +## Overview + +**AgentCronJob** is a new Kubernetes CRD (`kagent.dev/v1alpha2`) that schedules AI agent prompt execution on a cron schedule. It references an existing Agent CR, sends a static prompt at each tick via the kagent HTTP server API (same path as the UI), and stores results in sessions. + +Key design decisions: +- **Minimal spec:** schedule + prompt + agentRef (no concurrency policy, suspend, etc.) +- **RequeueAfter scheduling:** no in-memory cron library, survives restarts +- **No new DB models:** reuses sessions/tasks/events +- **HTTP server CRUD:** `/api/cronjobs` backs the existing UI placeholder +- **Error handling:** failed runs set status, retry on next tick + +## Suggested Next Steps + +1. **Implement** — Follow the 7-step plan in `plan.md`. Steps 2 and 3-5 can be parallelized. +2. **Review dependencies** — Confirm `robfig/cron/v3` is acceptable; it's the standard Go cron library. +3. **Consider future enhancements** — Concurrency policy, suspend/resume, prompt templating, execution timeout (all deferred by design). diff --git a/specs/cronjobs-empty-data-ui-error/design.md b/specs/cronjobs-empty-data-ui-error/design.md new file mode 100644 index 000000000..7e9db5171 --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/design.md @@ -0,0 +1,138 @@ +# Design: Fix CronJobs Empty Data UI Error + +## Overview + +When no CronJobs exist in the cluster, the CronJobs list page shows an error state instead of the empty state. This is caused by a two-layer bug: the Go backend omits the `data` field from the JSON response when the slice is nil (due to `omitempty`), and the UI treats a missing `data` field as an error. The fix applies to both layers, scoped to the CronJobs feature only. + +## Detailed Requirements + +1. Fix the backend `HandleListCronJobs` handler to always return a non-nil slice so `data` is present in the JSON response +2. Fix the UI `CronJobsPage` to treat missing/undefined `data` as an empty array instead of throwing an error +3. Keep the existing empty state UI unchanged (Clock icon + "No cron jobs found. Create one to get started.") +4. No changes to `StandardResponse` type or other endpoints + +## Architecture Overview + +```mermaid +sequenceDiagram + participant UI as CronJobs Page + participant Action as Server Action + participant API as Go HTTP Handler + participant K8s as Kubernetes API + + UI->>Action: getCronJobs() + Action->>API: GET /api/cronjobs + API->>K8s: List AgentCronJobs + K8s-->>API: AgentCronJobList (Items: nil or []) + Note over API: FIX: Ensure Items is [] not nil + API-->>Action: {"error":false,"data":[],"message":"..."} + Action-->>UI: BaseResponse with data: [] + Note over UI: FIX: Use data ?? [] as fallback + UI->>UI: Render empty state +``` + +## Components and Interfaces + +### Backend Change + +**File:** `go/core/internal/httpserver/handlers/cronjobs.go` +**Function:** `HandleListCronJobs` (line 28) + +Current code (line 43): +```go +data := api.NewResponse(cronJobList.Items, "Successfully listed AgentCronJobs", false) +``` + +Fixed code: +```go +items := cronJobList.Items +if items == nil { + items = []v1alpha2.AgentCronJob{} +} +data := api.NewResponse(items, "Successfully listed AgentCronJobs", false) +``` + +This ensures the JSON response always includes `"data": []` instead of omitting the field. + +### Frontend Change + +**File:** `ui/src/app/cronjobs/page.tsx` +**Function:** `fetchCronJobs` (line 49) + +Current code (lines 52-56): +```typescript +const response = await getCronJobs(); +if (response.error || !response.data) { + throw new Error(response.error || "Failed to fetch cron jobs"); +} +setCronJobs(response.data); +``` + +Fixed code: +```typescript +const response = await getCronJobs(); +if (response.error) { + throw new Error(response.error || "Failed to fetch cron jobs"); +} +setCronJobs(response.data ?? []); +``` + +The `!response.data` check is removed from the error condition. The nullish coalescing operator (`??`) ensures `cronJobs` state is always an array. + +## Data Models + +No changes to data models. Existing types are sufficient: + +- **Go:** `StandardResponse[[]v1alpha2.AgentCronJob]` — unchanged +- **TS:** `BaseResponse` — unchanged, `data` is already optional + +## Error Handling + +- **Backend:** If K8s API fails, existing error handling returns 500 (unchanged) +- **UI:** `response.error` (string from server action catch) still triggers error state +- **UI:** Network/timeout errors still caught by the try-catch block +- Only the "missing data = error" false positive is eliminated + +## Acceptance Criteria + +**Given** no AgentCronJob resources exist in the cluster +**When** the user navigates to the /cronjobs page +**Then** the page displays the empty state (Clock icon + "No cron jobs found. Create one to get started.") + +**Given** no AgentCronJob resources exist in the cluster +**When** the backend GET /api/cronjobs endpoint is called +**Then** the response body contains `"data": []` (not null, not omitted) + +**Given** the backend returns a valid error response +**When** the user navigates to the /cronjobs page +**Then** the ErrorState component renders with the error message (unchanged behavior) + +**Given** one or more AgentCronJob resources exist +**When** the user navigates to the /cronjobs page +**Then** the CronJobs are listed normally (unchanged behavior) + +## Testing Strategy + +### Backend +- Unit test for `HandleListCronJobs` with empty K8s response: verify JSON output contains `"data":[]` +- Unit test for `HandleListCronJobs` with populated response: verify existing behavior unchanged + +### Frontend +- Manual test: navigate to /cronjobs with no CronJobs in cluster, verify empty state renders +- Manual test: create a CronJob, verify list renders correctly +- Existing stub page test (`ui/src/app/__tests__/stub-pages.test.tsx`) should continue to pass + +## Appendices + +### Technology Choices +No new dependencies. Uses existing Go stdlib and TypeScript language features. + +### Research Findings +See `research/root-cause-analysis.md` for full investigation including: +- Go nil slice JSON marshaling behavior with `omitempty` +- All affected UI pages sharing the same pattern (out of scope for this fix) + +### Alternative Approaches Considered +1. **Remove `omitempty` from `StandardResponse.Data`** — rejected; too broad, may affect other endpoints +2. **UI-only fix** — rejected; leaves backend returning semantically incorrect response +3. **Fix all affected pages** — rejected; out of scope, can be done as follow-up diff --git a/specs/cronjobs-empty-data-ui-error/plan.md b/specs/cronjobs-empty-data-ui-error/plan.md new file mode 100644 index 000000000..139cd02fe --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/plan.md @@ -0,0 +1,88 @@ +# Implementation Plan: Fix CronJobs Empty Data UI Error + +## Checklist + +- [ ] Step 1: Fix backend handler (nil slice initialization) +- [ ] Step 2: Fix frontend fetch logic (remove false-positive error) +- [ ] Step 3: Add backend unit test +- [ ] Step 4: Verify end-to-end behavior + +--- + +## Step 1: Fix backend handler + +**Objective:** Ensure `HandleListCronJobs` always returns a non-nil slice so `data` is serialized as `[]` in JSON. + +**Implementation guidance:** +- File: `go/core/internal/httpserver/handlers/cronjobs.go` +- After the `KubeClient.List` call (line 40), add a nil check on `cronJobList.Items` +- If nil, assign `[]v1alpha2.AgentCronJob{}` +- Pass the initialized slice to `api.NewResponse` + +**Test requirements:** +- Compile succeeds (`go build ./...` from `go/core`) + +**Integration notes:** +- No API contract change — response shape is identical, just `data` is now always present + +**Demo:** `curl GET /api/cronjobs` on a cluster with no CronJobs returns `{"error":false,"data":[],"message":"Successfully listed AgentCronJobs"}` + +--- + +## Step 2: Fix frontend fetch logic + +**Objective:** Stop treating missing `data` as an error on the CronJobs page. + +**Implementation guidance:** +- File: `ui/src/app/cronjobs/page.tsx` +- In `fetchCronJobs()` (line 53), change `if (response.error || !response.data)` to `if (response.error)` +- Change `setCronJobs(response.data)` to `setCronJobs(response.data ?? [])` + +**Test requirements:** +- Existing test in `ui/src/app/__tests__/stub-pages.test.tsx` passes +- `npm run build` in `ui/` succeeds + +**Integration notes:** +- Empty state UI (lines 126-130) becomes reachable when `cronJobs.length === 0` + +**Demo:** Navigate to /cronjobs with no CronJobs — Clock icon and "No cron jobs found" message displayed instead of error. + +--- + +## Step 3: Add backend unit test + +**Objective:** Prevent regression by testing the empty list response. + +**Implementation guidance:** +- File: `go/core/internal/httpserver/handlers/cronjobs_test.go` (new or existing) +- Test case: mock K8s client returning empty `AgentCronJobList`, call `HandleListCronJobs`, assert response body contains `"data":[]` +- Test case: mock K8s client returning populated list, assert `data` contains the items + +**Test requirements:** +- `go test ./internal/httpserver/handlers/...` passes + +**Integration notes:** +- Follow existing test patterns in the handlers directory + +**Demo:** `go test -v -run TestHandleListCronJobs` shows both cases passing. + +--- + +## Step 4: Verify end-to-end behavior + +**Objective:** Confirm the fix works in a real environment. + +**Implementation guidance:** +- Deploy to local Kind cluster (`make helm-install`) +- Ensure no AgentCronJob CRs exist +- Open /cronjobs in browser — verify empty state renders +- Create a CronJob via the UI — verify it appears in the list +- Delete the CronJob — verify empty state returns + +**Test requirements:** +- All four acceptance criteria from design.md pass + +**Integration notes:** +- No E2E test automation required (UI resilience fix, not new API/CRD) + +**Demo:** Screenshot of empty state rendering correctly. diff --git a/specs/cronjobs-empty-data-ui-error/requirements.md b/specs/cronjobs-empty-data-ui-error/requirements.md new file mode 100644 index 000000000..7e23191dc --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/requirements.md @@ -0,0 +1,21 @@ +# Requirements + +## Q&A Record + +**Q1:** Should we fix only the CronJobs page, or also fix the same pattern in all other affected pages (git/page.tsx, models/page.tsx, plugins.ts, models/new/page.tsx)? + +**A1:** Fix only the CronJobs page. + +**Q2:** Should the fix be applied on both layers (backend: ensure non-nil slice in the CronJobs list handler + UI: treat missing data as empty array), or just one side? + +**A2:** Both layers — backend and UI. + +**Q3:** For the backend fix, should we initialize the nil slice only in the CronJobs handler, or also remove `omitempty` from `StandardResponse.Data` to prevent this class of bug for all endpoints? + +**A3:** Only in the CronJobs handler. + +**Q4:** Are there any additional requirements for the empty state UI beyond what currently exists (Clock icon + "No cron jobs found. Create one to get started." message), or is the current empty state design sufficient once it's reachable? + +**A4:** Current empty state design is sufficient — no changes needed. + + diff --git a/specs/cronjobs-empty-data-ui-error/research/backend-api.md b/specs/cronjobs-empty-data-ui-error/research/backend-api.md new file mode 100644 index 000000000..c17dbed8e --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/research/backend-api.md @@ -0,0 +1,47 @@ +# Research: CronJob Backend/API + +## Key Files + +| File | Purpose | +|------|---------| +| `go/api/v1alpha2/agentcronjob_types.go` | CRD type definitions | +| `go/core/internal/httpserver/handlers/cronjobs.go` | HTTP handlers | +| `go/core/internal/httpserver/server.go` (L278-283) | Route registration | +| `go/core/internal/controller/agentcronjob_controller.go` | Reconciliation logic | + +## API Endpoints + +- `GET /api/cronjobs` - List all (returns `[]AgentCronJob`) +- `GET /api/cronjobs/{namespace}/{name}` - Get one +- `POST /api/cronjobs` - Create +- `PUT /api/cronjobs/{namespace}/{name}` - Update +- `DELETE /api/cronjobs/{namespace}/{name}` - Delete + +## Response Format + +All endpoints use `StandardResponse[T]`: +```go +type StandardResponse[T any] struct { + Error bool `json:"error"` + Data T `json:"data,omitempty"` + Message string `json:"message,omitempty"` +} +``` + +## Empty List Response + +When no CronJobs exist, `cronJobList.Items` is an empty slice `[]`: +```json +{"error": false, "data": [], "message": "Successfully listed AgentCronJobs"} +``` + +**Important**: `data` field has `omitempty` JSON tag. If the Go slice is nil (not empty), the `data` field could be omitted from JSON entirely, resulting in: +```json +{"error": false, "message": "Successfully listed AgentCronJobs"} +``` + +This would cause `response.data` to be `undefined` in the UI, triggering the `!response.data` check and throwing an error. + +## Storage + +CronJobs are Kubernetes-native CRDs only - NOT stored in the database. diff --git a/specs/cronjobs-empty-data-ui-error/research/root-cause-analysis.md b/specs/cronjobs-empty-data-ui-error/research/root-cause-analysis.md new file mode 100644 index 000000000..584fe5b36 --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/research/root-cause-analysis.md @@ -0,0 +1,131 @@ +# Root Cause Analysis: CronJobs Empty Data UI Error + +## Problem + +When no CronJobs exist, the UI shows an error state instead of the empty state ("No cron jobs found"). + +## Root Cause + +Two-layer issue spanning backend and frontend: + +### Layer 1: Go nil slice JSON marshaling + +In `go/core/internal/httpserver/handlers/cronjobs.go:43`: +```go +data := api.NewResponse(cronJobList.Items, "Successfully listed AgentCronJobs", false) +``` + +`cronJobList.Items` is a nil `[]AgentCronJob` when no CronJobs exist. Go's `encoding/json` marshals nil slices as `null`, not `[]`. The `StandardResponse` uses `omitempty` on the `Data` field (`go/api/httpapi/types.go:27`): + +```go +Data T `json:"data,omitempty"` +``` + +With a nil slice `T`, `omitempty` causes the `data` field to be omitted entirely from the JSON response. The API returns: + +```json +{"error": false, "message": "Successfully listed AgentCronJobs"} +``` + +Instead of the expected: + +```json +{"error": false, "data": [], "message": "Successfully listed AgentCronJobs"} +``` + +### Layer 2: UI treats missing data as error + +In `ui/src/app/cronjobs/page.tsx:53`: +```typescript +if (response.error || !response.data) { + throw new Error(response.error || "Failed to fetch cron jobs"); +} +``` + +When `data` is `undefined` (omitted from JSON), `!response.data` is `true`, so the code throws an error. The `ErrorState` component renders instead of the empty state at line 126. + +## Data Flow + +``` +K8s API (0 CronJobs) → cronJobList.Items = nil []AgentCronJob + → NewResponse(nil, ...) → StandardResponse{Data: nil} + → JSON marshal with omitempty → {"error":false,"message":"..."} (no "data" field) + → fetchApi() → response.data = undefined + → page.tsx: !response.data → true → throws Error + → ErrorState component renders +``` + +## Scope of Impact + +This pattern (`response.error || !response.data`) appears in multiple pages: +- `ui/src/app/cronjobs/page.tsx:53` +- `ui/src/app/git/page.tsx:65,85` +- `ui/src/app/models/page.tsx:37` +- `ui/src/app/models/new/page.tsx:205,298,318,488,511` +- `ui/src/app/actions/plugins.ts:20` + +Any list endpoint returning an empty result could trigger the same bug. + +## Backend Response Type + +```go +// go/api/httpapi/types.go +type StandardResponse[T any] struct { + Error bool `json:"error"` + Data T `json:"data,omitempty"` + Message string `json:"message,omitempty"` +} +``` + +The `omitempty` on `Data` is the core backend issue. For slice types, Go considers nil slices as "empty" for omitempty purposes. + +## UI Type + +```typescript +// ui/src/types/index.ts +export interface BaseResponse { + message: string; + data?: T; // optional — undefined when backend omits it + error?: string; +} +``` + +## Fix Options + +### Option A: Backend fix — remove omitempty from Data field +Remove `omitempty` from `Data` in `StandardResponse`. This ensures `data` is always present in JSON (as `null` for nil values, `[]` for empty slices if initialized). + +**Risk:** Could change behavior for non-list endpoints where `Data` being absent was intentional. + +### Option B: Backend fix — initialize slice before response +In `HandleListCronJobs`, ensure the slice is non-nil: +```go +items := cronJobList.Items +if items == nil { + items = []v1alpha2.AgentCronJob{} +} +``` + +**Risk:** Must be done in every list handler. + +### Option C: UI fix — treat missing data as empty array for list endpoints +```typescript +setCronJobs(response.data ?? []); +``` + +**Risk:** Only fixes the symptom; other consumers may hit the same issue. + +### Option D: Combined fix (recommended) +1. Fix the specific UI page to handle missing data gracefully +2. Fix the backend to ensure list endpoints never return nil slices + +## Related Files + +| File | Role | +|------|------| +| `go/api/httpapi/types.go:16-29` | StandardResponse definition | +| `go/core/internal/httpserver/handlers/cronjobs.go:28-45` | List handler | +| `ui/src/app/actions/cronjobs.ts:6-27` | Server action | +| `ui/src/app/cronjobs/page.tsx:49-64` | Page fetch logic | +| `ui/src/app/cronjobs/page.tsx:126-130` | Empty state (unreachable currently) | +| `ui/src/types/index.ts:20-24` | BaseResponse type | diff --git a/specs/cronjobs-empty-data-ui-error/research/root-cause.md b/specs/cronjobs-empty-data-ui-error/research/root-cause.md new file mode 100644 index 000000000..59427489a --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/research/root-cause.md @@ -0,0 +1,37 @@ +# Research: Root Cause Analysis + +## The Bug + +When no CronJobs exist, the UI shows an error instead of an empty state. + +## Root Cause + +`go/api/httpapi/types.go:27` - `StandardResponse.Data` has `omitempty`: +```go +Data T `json:"data,omitempty"` +``` + +When the Go handler returns a nil or empty slice, `omitempty` causes `data` to be omitted from JSON. The UI receives `{"error":false,"message":"..."}` (no `data` key). The UI then treats `!response.data` (undefined) as an error. + +## Two Fix Strategies + +### Option A: Backend fix (preferred) +Remove `omitempty` from `Data` field. Empty slices serialize as `"data":[]`. +- Single fix, addresses all endpoints at once +- More correct semantics: `data` should always be present in a success response + +### Option B: Frontend fix +Change `!response.data` checks to treat missing data as empty array. +- Multiple files need changing +- Defensive but doesn't fix the root issue + +### Option C: Both +Fix backend (Option A) + make frontend resilient (Option B) for defense in depth. + +## Affected Pages (same `!response.data` pattern) + +- `ui/src/app/cronjobs/page.tsx` (L53) +- `ui/src/app/git/page.tsx` (L65, L85) +- `ui/src/app/models/page.tsx` (L37) +- `ui/src/app/models/new/page.tsx` (L205, L298, L318, L488, L511) +- `ui/src/app/actions/plugins.ts` (L20) diff --git a/specs/cronjobs-empty-data-ui-error/research/ui-cronjob-components.md b/specs/cronjobs-empty-data-ui-error/research/ui-cronjob-components.md new file mode 100644 index 000000000..43bedd004 --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/research/ui-cronjob-components.md @@ -0,0 +1,49 @@ +# Research: CronJob UI Components + +## Key Files + +| File | Purpose | +|------|---------| +| `ui/src/app/cronjobs/page.tsx` | Main list view - renders table of all cron jobs | +| `ui/src/app/cronjobs/new/page.tsx` | Create/Edit form | +| `ui/src/app/actions/cronjobs.ts` | API action functions (getCronJobs, createCronJob, etc.) | +| `ui/src/types/index.ts` (L438-467) | TypeScript interfaces | +| `ui/src/components/sidebars/AppSidebarNav.tsx` (L80) | Nav link to /cronjobs | + +## Data Flow + +1. `getCronJobs()` calls `fetchApi>("/cronjobs")` +2. Response checked: `if (response.error || !response.data)` -> throw +3. Data set via `setCronJobs(response.data)` +4. Empty list: renders "No cron jobs found" placeholder + +## Empty/Null Handling + +- **Empty list**: Shows placeholder with Clock icon and message (L126-130) +- **Optional chaining**: `job.status?.nextRunTime`, `job.status?.lastRunTime`, etc. +- **formatTime()**: Returns "N/A" for falsy/invalid timestamps +- **lastResult**: Falls back to "N/A" when undefined +- **Status is optional**: `AgentCronJob.status?: AgentCronJobStatus` + +## Error Handling Pattern + +```typescript +// API layer +export async function getCronJobs(): Promise> { + try { + const response = await fetchApi>("/cronjobs"); + if (!response) throw new Error("Failed to get cron jobs"); + response.data?.sort(...); + return { message: "...", data: response.data }; + } catch (error) { + return createErrorResponse(error, "Error getting cron jobs"); + } +} + +// Component layer +const response = await getCronJobs(); +if (response.error || !response.data) { + throw new Error(response.error || "Failed to fetch cron jobs"); +} +setCronJobs(response.data); +``` diff --git a/specs/cronjobs-empty-data-ui-error/rough-idea.md b/specs/cronjobs-empty-data-ui-error/rough-idea.md new file mode 100644 index 000000000..aed75fe84 --- /dev/null +++ b/specs/cronjobs-empty-data-ui-error/rough-idea.md @@ -0,0 +1,3 @@ +# Rough Idea + +Fix CronJobs empty data UI error diff --git a/specs/dashboard-page/PROMPT.md b/specs/dashboard-page/PROMPT.md new file mode 100644 index 000000000..ecb02c97f --- /dev/null +++ b/specs/dashboard-page/PROMPT.md @@ -0,0 +1,53 @@ +# Dashboard Page + +## Objective + +Add a Dashboard page at `/` replacing the current AgentList home page. The dashboard shows resource counts, an activity chart (mock data), recent runs, and a live event feed. Includes a Go backend stats endpoint and a recharts-based frontend. + +## Key Requirements + +1. **Backend endpoint** `GET /api/dashboard/stats` — returns resource counts (7 types), recent sessions (limit 10), recent events (limit 20) via DB COUNT queries and K8s list calls +2. **7 stat cards** — Agents, Workflows, Cron Jobs, Models, Tools, MCP Servers, Git Repos (static, not clickable) +3. **Activity chart** — recharts ComposedChart (line + bar) with mock data; real Prometheus/Temporal data later +4. **Recent Runs panel** — list of recent sessions with agent name + relative timestamp +5. **Live Feed panel** — pseudo-feed of recent session events from DB (not truly live) +6. **Top bar** — namespace selector, "Stream Connected" badge (green dot + wifi icon), logout button +7. **Replace `/` route** — remove AgentList from page.tsx (it already exists at `/agents`) +8. **Data on page load only** — no auto-refresh or polling +9. **Graceful degradation** — if a K8s resource type fails (CRD not installed), return count 0 + +## Acceptance Criteria + +- Given a user navigates to `/`, then the Dashboard page renders (not AgentList) +- Given the dashboard loads, then 7 stat cards display correct counts from the stats endpoint +- Given the dashboard loads, then the Activity Chart renders with mock data using recharts +- Given the dashboard loads, then Recent Runs shows up to 10 sessions with agent name and relative time +- Given the dashboard loads, then Live Feed shows up to 20 events with summary and relative time +- Given the stats endpoint is unreachable, then an error state with retry button is shown +- Given a K8s CRD is not installed, then that resource count returns 0 (no error) +- Given a desktop viewport, then stat cards render in a single row of 7 +- Given a mobile viewport, then stat cards render in a 2-column grid + +## Reference + +Full specs at `specs/dashboard-page/`: +- `design.md` — architecture, components, interfaces, data models, error handling +- `plan.md` — 13-step implementation plan with checklist +- `requirements.md` — 9 Q&A decisions defining scope +- `research/` — codebase research on UI structure, API sources, components, streaming + +## Implementation Steps + +1. Backend: Add response types to `go/api/httpapi/types.go` +2. Backend: Add DB methods (`CountSessions`, `RecentSessions`, `RecentEvents`) +3. Backend: Create handler `go/core/internal/httpserver/handlers/dashboard.go` + register route +4. Backend: Handler unit tests (happy path, partial failure, empty state) +5. Frontend: Add TS types to `ui/src/types/index.ts` + server action `ui/src/app/actions/dashboard.ts` +6. Frontend: `StatCard` + `StatsRow` components in `ui/src/components/dashboard/` +7. Frontend: Install recharts + `ActivityChart` component with mock data +8. Frontend: `RecentRunsPanel` component +9. Frontend: `LiveFeedPanel` component +10. Frontend: `DashboardTopBar` component +11. Frontend: Wire everything in `ui/src/app/page.tsx` +12. Frontend: Component tests +13. Integration: Build, lint, end-to-end verification diff --git a/specs/dashboard-page/design.md b/specs/dashboard-page/design.md new file mode 100644 index 000000000..6da81eba4 --- /dev/null +++ b/specs/dashboard-page/design.md @@ -0,0 +1,401 @@ +# Dashboard Page — Detailed Design + +## Overview + +Add a Dashboard page to kagent as the landing page at `/`, replacing the current AgentList home page. The dashboard provides a high-level overview of the KAgent cluster: resource counts, agent activity chart, recent runs, and a live event feed. It includes a small backend stats endpoint and a recharts-based activity chart with mock data (to be wired to Prometheus/Temporal later). + +## Detailed Requirements + +1. **Dashboard replaces `/`** — the current AgentList at `/` is removed (it already exists at `/agents`) +2. **Backend stats endpoint** — `GET /api/dashboard/stats` returns resource counts, recent sessions, and recent events via DB COUNT queries +3. **7 stat cards** — My Agents, Workflows, Cron Jobs, Models, Tools, MCP Servers, Git Repos (static, not clickable) +4. **Activity chart** — recharts combined line+bar chart with mock data; real data from Prometheus/Temporal later +5. **Recent Runs panel** — list of recent sessions from DB +6. **Live Feed panel** — pseudo-feed of recent session events from DB (not truly live/streaming) +7. **Top bar** — namespace selector, "Stream Connected" badge, logout button +8. **Data on page load only** — no auto-refresh or polling + +## Architecture Overview + +```mermaid +graph TD + subgraph Frontend ["Frontend (Next.js)"] + DP[Dashboard Page
/] + SA[Server Action
getDashboardStats] + SC[Stat Cards] + AC[Activity Chart
recharts + mock data] + RR[Recent Runs Panel] + LF[Live Feed Panel] + TB[Top Bar] + end + + subgraph Backend ["Backend (Go)"] + H[HTTP Handler
HandleDashboardStats] + DB[(Database
SQLite/Postgres)] + end + + DP --> TB + DP --> SC + DP --> AC + DP --> RR + DP --> LF + DP --> SA + SA -->|GET /api/dashboard/stats| H + H -->|COUNT queries| DB + H -->|Recent sessions| DB + H -->|Recent events| DB +``` + +## Components and Interfaces + +### 1. Backend: Dashboard Stats Endpoint + +**Route:** `GET /api/dashboard/stats` + +**Handler location:** `go/core/internal/httpserver/handlers/dashboard.go` + +**Response type** (add to `go/api/httpapi/types.go`): + +```go +type DashboardStatsResponse struct { + Counts DashboardCounts `json:"counts"` + RecentRuns []RecentRun `json:"recentRuns"` + RecentEvents []RecentEvent `json:"recentEvents"` +} + +type DashboardCounts struct { + Agents int `json:"agents"` + Workflows int `json:"workflows"` + CronJobs int `json:"cronJobs"` + Models int `json:"models"` + Tools int `json:"tools"` + MCPServers int `json:"mcpServers"` + GitRepos int `json:"gitRepos"` +} + +type RecentRun struct { + SessionID string `json:"sessionId"` + SessionName string `json:"sessionName"` + AgentName string `json:"agentName"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type RecentEvent struct { + ID uint `json:"id"` + SessionID string `json:"sessionId"` + Summary string `json:"summary"` + CreatedAt string `json:"createdAt"` +} +``` + +**Database queries:** +- Counts: `SELECT COUNT(*) FROM agents`, `SELECT COUNT(*) FROM tool_servers`, etc. +- For K8s-only resources (agents, workflows, cron jobs, models): use existing K8s list handlers internally or the DB agent table + K8s API +- Recent runs: `SELECT * FROM sessions WHERE user_id = ? ORDER BY updated_at DESC LIMIT 10` +- Recent events: `SELECT * FROM events ORDER BY created_at DESC LIMIT 20` + +**DB Client additions** (add to `go/api/database/client.go` interface): + +```go +// Dashboard stats +CountSessions(userID string) (int64, error) +RecentSessions(userID string, limit int) ([]Session, error) +RecentEvents(limit int) ([]Event, error) +``` + +**Note on K8s resources:** Agents, Workflows, CronJobs, Models, MCPServers are K8s CRs. Their counts come from the existing K8s list logic already used by other handlers (e.g., `HandleListAgents`). The handler will call these internally and count the results. + +Tools and ToolServers are DB-backed, so counts come from DB queries. + +### 2. Frontend: Server Action + +**File:** `ui/src/app/actions/dashboard.ts` + +```typescript +export async function getDashboardStats(): Promise { + return fetchApi("/api/dashboard/stats"); +} +``` + +### 3. Frontend: TypeScript Types + +**Add to `ui/src/types/index.ts`:** + +```typescript +interface DashboardCounts { + agents: number; + workflows: number; + cronJobs: number; + models: number; + tools: number; + mcpServers: number; + gitRepos: number; +} + +interface RecentRun { + sessionId: string; + sessionName: string; + agentName: string; + createdAt: string; + updatedAt: string; +} + +interface RecentEvent { + id: number; + sessionId: string; + summary: string; + createdAt: string; +} + +interface DashboardStatsResponse { + counts: DashboardCounts; + recentRuns: RecentRun[]; + recentEvents: RecentEvent[]; +} +``` + +### 4. Frontend: Dashboard Page Component + +**File:** `ui/src/app/page.tsx` (replaces current AgentList) + +``` +DashboardPage + DashboardTopBar + NamespaceSelector (duplicate from sidebar for quick access) + StreamStatusBadge ("Stream Connected" green dot + wifi icon) + LogoutButton + PageHeader ("Dashboard" / "Overview of your KAgent cluster") + StatsRow + StatCard x7 (icon + label + count) + ActivityChart (recharts, mock data) + BottomRow (grid cols-2) + RecentRunsPanel (left) + LiveFeedPanel (right) +``` + +### 5. Frontend: StatCard Component + +**File:** `ui/src/components/dashboard/StatCard.tsx` + +Uses Shadcn Card primitives. Displays: +- Lucide icon (matches resource type) +- Uppercase label (e.g., "MY AGENTS") +- Count number (large text) + +``` +┌──────────────┐ +│ [icon] LABEL │ +│ 3 │ +└──────────────┘ +``` + +**Props:** +```typescript +interface StatCardProps { + icon: LucideIcon; + label: string; + count: number; +} +``` + +**Layout:** 7 cards in a responsive row: +- Desktop: `grid grid-cols-7 gap-4` +- Tablet: `grid grid-cols-4 gap-4` (wraps to 2 rows) +- Mobile: `grid grid-cols-2 gap-4` + +### 6. Frontend: ActivityChart Component + +**File:** `ui/src/components/dashboard/ActivityChart.tsx` + +**Dependencies:** `recharts` (new dependency to install) + +**Structure:** +- Card wrapper with title "Agent Activity" and subtitle +- Time range toggle tabs: Avg | P95 | 1h | **24hr** (active) | 7d — non-functional for now (mock data doesn't change) +- Summary stats row: Total runs, Avg duration, Failed runs, Failure rate +- Combined chart using recharts `ComposedChart`: + - `Line` — avg run duration (blue, `--chart-1`) + - `Bar` — agents installed (teal, `--chart-2`) + - `Bar` — failed buckets (red/destructive, `--chart-3`) +- X-axis: time labels (hourly buckets) +- Legend at bottom + +**Mock data:** Generate 24 hourly data points with realistic-looking values. Export as a constant so it's easy to swap for real Prometheus data later. + +```typescript +interface ActivityDataPoint { + time: string; // "9p", "12a", "3a", etc. + avgDuration: number; // seconds + agentRuns: number; // count + failedRuns: number; // count +} + +const MOCK_ACTIVITY_DATA: ActivityDataPoint[] = [/* 24 data points */]; +``` + +### 7. Frontend: RecentRunsPanel Component + +**File:** `ui/src/components/dashboard/RecentRunsPanel.tsx` + +- Card with header "Recent Runs" + "View all" link (points to `/agents`) +- List of recent sessions from `DashboardStatsResponse.recentRuns` +- Each row shows: agent name, session name (or ID), relative timestamp ("2m ago") +- Empty state: "No recent runs" +- Max 10 items, scrollable if needed + +### 8. Frontend: LiveFeedPanel Component + +**File:** `ui/src/components/dashboard/LiveFeedPanel.tsx` + +- Card with header "Live Feed" + green dot indicator + event count +- List of recent events from `DashboardStatsResponse.recentEvents` +- Each row shows: summary text, relative timestamp +- Empty state: "No events" +- Max 20 items, scrollable + +### 9. Frontend: DashboardTopBar Component + +**File:** `ui/src/components/dashboard/DashboardTopBar.tsx` + +- Flex row with justify-between +- Left: Namespace selector dropdown (reuse existing `NamespaceSelector` component) +- Right: "Stream Connected" badge (green dot + Wifi icon + text) + Logout button (LogOut icon) +- The stream badge is visual-only for now (always shows "Connected") + +## Data Models + +### Backend Response (single endpoint) + +```json +{ + "counts": { + "agents": 3, + "workflows": 0, + "cronJobs": 3, + "models": 4, + "tools": 3, + "mcpServers": 2, + "gitRepos": 0 + }, + "recentRuns": [ + { + "sessionId": "abc-123", + "sessionName": "Debug production issue", + "agentName": "k8s-helper", + "createdAt": "2026-03-06T10:30:00Z", + "updatedAt": "2026-03-06T10:35:00Z" + } + ], + "recentEvents": [ + { + "id": 42, + "sessionId": "abc-123", + "summary": "Agent k8s-helper started", + "createdAt": "2026-03-06T10:30:00Z" + } + ] +} +``` + +### Icon Mapping for Stat Cards + +| Card | Icon (Lucide) | Label | +|------|--------------|-------| +| Agents | `Bot` | MY AGENTS | +| Workflows | `GitBranch` | WORKFLOWS | +| Cron Jobs | `Clock` | CRON JOBS | +| Models | `Brain` | MODELS | +| Tools | `Wrench` | TOOLS | +| MCP Servers | `Server` | MCP SERVERS | +| Git Repos | `GitFork` | GIT REPOS | + +These match the icons already used in `AppSidebarNav.tsx` NAV_SECTIONS. + +## Error Handling + +- **Stats fetch failure:** Show error state with retry button (reuse existing `ErrorState` pattern) +- **Partial data:** If some K8s list calls fail (e.g., workflows CRD not installed), return 0 for that count and continue +- **Loading state:** Show skeleton cards and chart placeholder while data loads (reuse `LoadingState` pattern) + +## Acceptance Criteria + +### Stats Endpoint +- Given a user requests `GET /api/dashboard/stats`, when the request is authenticated, then return counts for all 7 resource types, up to 10 recent sessions, and up to 20 recent events +- Given the workflows CRD is not installed, when stats are requested, then workflows count returns 0 (no error) + +### Dashboard Page +- Given the user navigates to `/`, then the Dashboard page renders (not AgentList) +- Given the dashboard loads, then 7 stat cards display with correct counts from the stats endpoint +- Given the dashboard loads, then the Activity Chart renders with mock data using recharts +- Given the dashboard loads, then Recent Runs shows up to 10 recent sessions with agent name and relative timestamp +- Given the dashboard loads, then Live Feed shows up to 20 recent events with summary and relative timestamp +- Given the stats endpoint is unreachable, then an error state with retry button is shown + +### Top Bar +- Given the dashboard renders, then the top bar shows namespace selector, "Stream Connected" badge, and logout button +- Given the user changes namespace in the top bar selector, then the dashboard data refreshes for the selected namespace + +### Responsive Layout +- Given a desktop viewport (>1024px), then stat cards render in a single row of 7 +- Given a tablet viewport (768-1024px), then stat cards wrap to 2 rows +- Given a mobile viewport (<768px), then stat cards render in 2-column grid + +## Testing Strategy + +### Go Backend +- **Unit tests** for `HandleDashboardStats` handler: + - Mock DB client returning known counts + - Verify response shape and status codes + - Test with missing/errored K8s resources (graceful degradation) +- **Unit tests** for new DB client methods: + - `CountSessions`, `RecentSessions`, `RecentEvents` + +### Frontend +- **Component tests** (Jest/React Testing Library): + - StatCard renders icon, label, count + - StatsRow renders 7 cards with correct data + - RecentRunsPanel renders session list and empty state + - LiveFeedPanel renders event list and empty state + - DashboardPage integrates all components +- **Cypress E2E** (if needed): + - Navigate to `/`, verify dashboard renders + - Verify stat cards show numeric counts + +## Appendices + +### Technology Choices +- **recharts** — most popular React charting library, works well with Shadcn/UI and Tailwind, supports ComposedChart for mixed line+bar +- **Existing Shadcn Card** — reused for stat cards, panels, and chart wrapper +- **Existing patterns** — LoadingState, ErrorState, NamespaceSelector reused from current codebase + +### Files to Create +| File | Type | Purpose | +|------|------|---------| +| `go/core/internal/httpserver/handlers/dashboard.go` | Go | Stats handler | +| `go/core/internal/httpserver/handlers/dashboard_test.go` | Go | Handler tests | +| `go/api/httpapi/types.go` (edit) | Go | Add response types | +| `go/api/database/client.go` (edit) | Go | Add count/recent methods | +| `go/core/internal/database/` (edit) | Go | Implement new DB methods | +| `go/core/internal/httpserver/server.go` (edit) | Go | Register route | +| `ui/src/app/page.tsx` (edit) | TS | Replace AgentList with Dashboard | +| `ui/src/app/actions/dashboard.ts` | TS | Server action | +| `ui/src/types/index.ts` (edit) | TS | Add dashboard types | +| `ui/src/components/dashboard/StatCard.tsx` | TS | Stat card component | +| `ui/src/components/dashboard/StatsRow.tsx` | TS | 7-card grid | +| `ui/src/components/dashboard/ActivityChart.tsx` | TS | Recharts chart | +| `ui/src/components/dashboard/RecentRunsPanel.tsx` | TS | Recent runs list | +| `ui/src/components/dashboard/LiveFeedPanel.tsx` | TS | Event feed list | +| `ui/src/components/dashboard/DashboardTopBar.tsx` | TS | Top bar with controls | + +### Alternative Approaches Considered +- **Client-side aggregation** — fetching all list endpoints and counting in the browser. Rejected: inefficient, fetches full payloads just for counts. +- **Omit activity chart** — simpler initial version. Rejected: user wants chart UI ready with mock data for Prometheus integration later. +- **SSE live feed** — real streaming for the feed panel. Deferred: no backend event bus exists. Pseudo-feed from recent DB events is sufficient for now. + +### Future Enhancements (Out of Scope) +- Wire activity chart to Prometheus/Temporal metrics +- Real-time live feed via SSE +- Clickable stat cards linking to resource pages +- Auto-refresh / polling +- Run duration and success/failure tracking in Task model diff --git a/specs/dashboard-page/image.png b/specs/dashboard-page/image.png new file mode 100644 index 000000000..e71d10672 Binary files /dev/null and b/specs/dashboard-page/image.png differ diff --git a/specs/dashboard-page/plan.md b/specs/dashboard-page/plan.md new file mode 100644 index 000000000..5bd84b8cd --- /dev/null +++ b/specs/dashboard-page/plan.md @@ -0,0 +1,339 @@ +# Dashboard Page — Implementation Plan + +## Checklist + +- [ ] Step 1: Backend — Dashboard stats response types +- [ ] Step 2: Backend — DB client methods for counts and recents +- [ ] Step 3: Backend — Dashboard HTTP handler + route registration +- [ ] Step 4: Backend — Handler unit tests +- [ ] Step 5: Frontend — TypeScript types + server action +- [ ] Step 6: Frontend — StatCard + StatsRow components +- [ ] Step 7: Frontend — Install recharts + ActivityChart component +- [ ] Step 8: Frontend — RecentRunsPanel component +- [ ] Step 9: Frontend — LiveFeedPanel component +- [ ] Step 10: Frontend — DashboardTopBar component +- [ ] Step 11: Frontend — Dashboard page (wire everything together) +- [ ] Step 12: Frontend — Component tests +- [ ] Step 13: Integration — End-to-end verification + +--- + +## Step 1: Backend — Dashboard stats response types + +**Objective:** Define the API response types for the dashboard stats endpoint. + +**Implementation:** +- Edit `go/api/httpapi/types.go` — add `DashboardStatsResponse`, `DashboardCounts`, `RecentRun`, `RecentEvent` structs +- All fields have JSON tags matching the frontend contract + +**Test requirements:** +- Types compile correctly (verified by build) + +**Integration notes:** +- These types are shared between handler and frontend — define them first to establish the contract + +**Demo:** `go build ./...` passes with new types + +--- + +## Step 2: Backend — DB client methods for counts and recents + +**Objective:** Add database query methods needed by the dashboard handler. + +**Implementation:** +- Edit `go/api/database/client.go` — add interface methods: + - `CountSessions(userID string) (int64, error)` + - `RecentSessions(userID string, limit int) ([]Session, error)` + - `RecentEvents(limit int) ([]Event, error)` +- Edit DB implementation (e.g., `go/core/internal/database/`) — implement with GORM: + - `CountSessions`: `db.Model(&Session{}).Where("user_id = ?", userID).Count(&count)` + - `RecentSessions`: `db.Where("user_id = ?", userID).Order("updated_at DESC").Limit(limit).Find(&sessions)` + - `RecentEvents`: `db.Order("created_at DESC").Limit(limit).Find(&events)` + +**Test requirements:** +- Unit tests for each DB method with mock/test DB + +**Integration notes:** +- These methods are consumed by the handler in Step 3 + +**Demo:** DB methods return correct counts and ordered results against test data + +--- + +## Step 3: Backend — Dashboard HTTP handler + route registration + +**Objective:** Create the handler that serves `GET /api/dashboard/stats` and register the route. + +**Implementation:** +- Create `go/core/internal/httpserver/handlers/dashboard.go`: + - `DashboardHandler` struct with DB client + K8s list dependencies + - `HandleDashboardStats(w, r)` method: + 1. Get userID from auth context + 2. Fetch K8s resource counts (agents, workflows, cronjobs, models, MCP servers) via existing list logic — count results, return 0 on error + 3. Fetch DB counts (tools, tool servers) via DB client + 4. Fetch recent sessions (limit 10) and recent events (limit 20) + 5. Build `DashboardStatsResponse` and write JSON +- Edit `go/core/internal/httpserver/server.go`: + - Add `Dashboard` field to handlers struct + - Register route: `GET /api/dashboard/stats` → `HandleDashboardStats` +- Wire handler in server initialization with DB client and K8s dependencies + +**Test requirements:** +- Deferred to Step 4 + +**Integration notes:** +- Handler reuses existing K8s list patterns from other handlers for resource counts +- Graceful degradation: if a K8s resource type fails (CRD not installed), return count 0 + +**Demo:** `curl localhost:8080/api/dashboard/stats` returns JSON with counts, recent runs, and events + +--- + +## Step 4: Backend — Handler unit tests + +**Objective:** Test the dashboard handler with mocked dependencies. + +**Implementation:** +- Create `go/core/internal/httpserver/handlers/dashboard_test.go`: + - Mock DB client returning known counts/sessions/events + - Mock K8s list responses + - Table-driven tests: + - Happy path: all resources available, verify response shape + - Partial failure: some K8s lists fail, verify 0 counts (no error) + - Empty state: no sessions or events, verify empty arrays + - Auth: verify userID is extracted and passed to DB + +**Test requirements:** +- All tests pass with `go test ./go/core/internal/httpserver/handlers/ -run TestDashboard` + +**Integration notes:** +- Follow existing handler test patterns in the `handlers/` directory + +**Demo:** `go test` passes, handler tested with 3+ scenarios + +--- + +## Step 5: Frontend — TypeScript types + server action + +**Objective:** Define the frontend types and data fetching function. + +**Implementation:** +- Edit `ui/src/types/index.ts` — add `DashboardCounts`, `RecentRun`, `RecentEvent`, `DashboardStatsResponse` interfaces +- Create `ui/src/app/actions/dashboard.ts`: + - `getDashboardStats()` function using `fetchApi("/api/dashboard/stats")` + +**Test requirements:** +- Types compile correctly (verified by `npm run build`) + +**Integration notes:** +- Server action follows the same pattern as other actions in `ui/src/app/actions/` + +**Demo:** Types available for import, action callable from components + +--- + +## Step 6: Frontend — StatCard + StatsRow components + +**Objective:** Build the stat card grid showing 7 resource counts. + +**Implementation:** +- Create `ui/src/components/dashboard/StatCard.tsx`: + - Props: `{ icon: LucideIcon, label: string, count: number }` + - Uses Shadcn `Card` with centered layout + - Icon (muted color) + uppercase label (small text) + count (large bold text) +- Create `ui/src/components/dashboard/StatsRow.tsx`: + - Props: `{ counts: DashboardCounts }` + - Maps counts to 7 StatCards with correct icons (Bot, GitBranch, Clock, Brain, Wrench, Server, GitFork) + - Responsive grid: `grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-4` + +**Test requirements:** +- StatCard renders icon, label, and count +- StatsRow renders 7 cards with correct data mapping + +**Integration notes:** +- Icons match sidebar nav icons from `AppSidebarNav.tsx` + +**Demo:** StatsRow renders with sample data, responsive at breakpoints + +--- + +## Step 7: Frontend — Install recharts + ActivityChart component + +**Objective:** Add recharts dependency and build the activity chart with mock data. + +**Implementation:** +- Install: `npm install recharts` in `ui/` +- Create `ui/src/components/dashboard/ActivityChart.tsx`: + - Mock data: `MOCK_ACTIVITY_DATA` — 24 hourly data points with `time`, `avgDuration`, `agentRuns`, `failedRuns` + - Summary stats row: Total runs, Avg duration (cyan), Failed runs (red), Failure rate + - Time range toggle using Shadcn Tabs (Avg | P95 | 1h | 24hr | 7d) — visual only, doesn't filter + - recharts `ResponsiveContainer` + `ComposedChart`: + - `Line` for avg duration (using `--chart-1` color) + - `Bar` for agent runs (using `--chart-2` color) + - `Bar` for failed runs (using `--chart-3` / destructive color) + - `XAxis`, `YAxis`, `Tooltip`, `Legend` + - Wrapped in Shadcn Card + +**Test requirements:** +- Component renders without errors +- Mock data is displayed (chart renders with data points) + +**Integration notes:** +- Uses CSS variable chart colors for theme consistency +- Mock data exported separately so it's easy to swap for Prometheus data later +- `ResponsiveContainer` ensures chart resizes with parent + +**Demo:** Chart renders with combined line+bar visualization and legend + +--- + +## Step 8: Frontend — RecentRunsPanel component + +**Objective:** Show a list of recent agent sessions. + +**Implementation:** +- Create `ui/src/components/dashboard/RecentRunsPanel.tsx`: + - Props: `{ runs: RecentRun[] }` + - Shadcn Card with header "Recent Runs" + "View all" link to `/agents` + - List items: agent name (bold) + session name + relative time ("2m ago") + - Empty state: "No recent runs" with muted text + - ScrollArea with max height for overflow + +**Test requirements:** +- Renders list of runs with correct data +- Shows empty state when runs array is empty +- "View all" link points to `/agents` + +**Integration notes:** +- Use `formatDistanceToNow` from date-fns (already in project) or simple relative time helper + +**Demo:** Panel shows run list with agent names and timestamps + +--- + +## Step 9: Frontend — LiveFeedPanel component + +**Objective:** Show a pseudo-feed of recent session events. + +**Implementation:** +- Create `ui/src/components/dashboard/LiveFeedPanel.tsx`: + - Props: `{ events: RecentEvent[] }` + - Shadcn Card with header "Live Feed" + green dot indicator + event count badge + - List items: event summary text + relative timestamp + - Empty state: "No events" with "0 events" badge + - ScrollArea with max height for overflow + +**Test requirements:** +- Renders list of events with correct data +- Shows event count in header badge +- Shows empty state when events array is empty + +**Integration notes:** +- Green dot uses same styling pattern as StatusIndicator (`bg-green-500 rounded-full`) + +**Demo:** Panel shows event list with summaries and timestamps + +--- + +## Step 10: Frontend — DashboardTopBar component + +**Objective:** Build the top bar with namespace selector, stream status, and logout. + +**Implementation:** +- Create `ui/src/components/dashboard/DashboardTopBar.tsx`: + - Flex row: left side (namespace selector) + right side (status badge + logout) + - Namespace selector: reuse existing `NamespaceSelector` component or pattern from sidebar + - Stream status badge: green dot + Wifi icon + "Stream Connected" text (visual-only, always "connected") + - Logout button: LogOut icon button + +**Test requirements:** +- Renders namespace selector, status badge, and logout button +- Status badge shows "Stream Connected" with green indicator + +**Integration notes:** +- Uses `useNamespace()` context from existing namespace provider +- Logout behavior: TBD based on existing auth patterns (may just be visual for now) + +**Demo:** Top bar renders with all three controls + +--- + +## Step 11: Frontend — Dashboard page (wire everything together) + +**Objective:** Replace the current AgentList at `/` with the full Dashboard page. + +**Implementation:** +- Edit `ui/src/app/page.tsx`: + - Remove AgentList import and rendering + - Create Dashboard client component that: + 1. Calls `getDashboardStats()` on mount + 2. Manages loading/error/data states + 3. Renders: DashboardTopBar → page title/subtitle → StatsRow → ActivityChart → bottom row (RecentRunsPanel + LiveFeedPanel) + - Loading state: skeleton cards + chart placeholder + - Error state: reuse existing ErrorState pattern with retry + +**Test requirements:** +- Page renders all sections in correct layout order +- Loading state shows skeletons +- Error state shows retry button + +**Integration notes:** +- Layout: `space-y-6` vertical stack for sections +- Bottom row: `grid grid-cols-1 md:grid-cols-2 gap-6` +- Page title: "Dashboard" with subtitle "Overview of your KAgent cluster" + +**Demo:** Navigate to `/` — full dashboard renders with stats, chart, runs, and feed + +--- + +## Step 12: Frontend — Component tests + +**Objective:** Add unit tests for all dashboard components. + +**Implementation:** +- Create test files alongside components: + - `ui/src/components/dashboard/__tests__/StatCard.test.tsx` + - `ui/src/components/dashboard/__tests__/StatsRow.test.tsx` + - `ui/src/components/dashboard/__tests__/RecentRunsPanel.test.tsx` + - `ui/src/components/dashboard/__tests__/LiveFeedPanel.test.tsx` +- Test each component with mock props, verify: + - Correct rendering of data + - Empty states + - Links and navigation + +**Test requirements:** +- All component tests pass with `npm test` + +**Integration notes:** +- Follow existing test patterns from `ui/src/components/sidebars/__tests__/` + +**Demo:** `npm test` passes with all dashboard component tests green + +--- + +## Step 13: Integration — End-to-end verification + +**Objective:** Verify the full stack works together. + +**Implementation:** +- Build Go backend: `make -C go build` +- Build UI: `make -C ui build` +- Run `make lint` — ensure no lint errors +- Manual verification against the design sketch: + - 7 stat cards render with counts + - Activity chart shows mock data with line + bars + - Recent Runs panel shows sessions + - Live Feed panel shows events + - Top bar has namespace selector, status badge, logout + - Responsive layout works at desktop/tablet/mobile breakpoints + +**Test requirements:** +- All Go tests pass: `make -C go test` +- All UI tests pass: `make -C ui test` +- Build succeeds: `make build` +- Lint passes: `make lint` + +**Integration notes:** +- Compare rendered page against rough-idea.md layout sketch + +**Demo:** Full dashboard page running locally, matching the design sketch diff --git a/specs/dashboard-page/requirements.md b/specs/dashboard-page/requirements.md new file mode 100644 index 000000000..be00e7e7e --- /dev/null +++ b/specs/dashboard-page/requirements.md @@ -0,0 +1,110 @@ +# Requirements + +## Questions & Answers + +### Q1: Scope — frontend-only or full-stack? + +The sketch shows stats (agent count, run counts, failure rates) and an activity chart that require data the backend doesn't currently aggregate. Should this design: + +- **(A) Frontend-only (Phase 1):** Dashboard fetches existing list endpoints (`/api/agents`, `/api/sessions`, `/api/tools`, etc.), counts client-side. Activity chart and run stats are stubbed or omitted until backend support exists. +- **(B) Full-stack:** Includes a new `GET /api/dashboard/stats` backend endpoint that returns pre-aggregated counts, run history buckets, failure rates, and avg duration. +- **(C) Both, phased:** Frontend-first with client-side counts, then a follow-up step adds the backend stats endpoint for the activity chart. + +**A1:** (A) Frontend-only. Client-side aggregation from existing list endpoints. Activity chart and run stats stubbed/omitted for now. + +### Q2: Stats row — which cards to show? + +The sketch shows 7 metric cards: My Agents, Workflows, Cron Jobs, Models, Tools, MCP Servers, GIT Repos. Given that some of these are placeholder pages (Workflows is "Coming soon"), should we: + +- **(A) Show all 7** — display counts for all resources, even if some are always 0 +- **(B) Show only active resources** — only cards for resources with working pages (Agents, Cron Jobs, Models, Tools, MCP Servers, Git Repos — skip Workflows) +- **(C) Show all 7 but mark placeholders** — show all cards, dim or badge the ones that are "Coming soon" + +**A2:** (A) Show all 7 cards. Display counts for all resources regardless of page status. + +### Q3: Agent Activity chart — include or stub? + +The sketch shows a combined line+bar chart with time-series data (run duration, agent installs, failed buckets). Since we're going frontend-only and there's no time-series backend endpoint, and no chart library is installed: + +- **(A) Omit entirely** — skip the chart section for now, just show stats row + bottom panels +- **(B) Stub with placeholder** — show the chart area with "Coming soon" or sample/mock data +- **(C) Install recharts and build with mock data** — wire up the chart UI with realistic-looking static data, ready to connect to a real endpoint later + +**A3:** Data for the activity chart will come from Prometheus (workflow metrics from Temporal). So the chart is real but the data source is external (Prometheus/Temporal), not the kagent DB. + +**Follow-up:** Should we install recharts now and build the chart UI with mock/placeholder data (ready to wire to Prometheus later), or stub the chart area as "Coming soon"? + +**A3 (final):** (B) Install recharts, build chart UI with mock data. Chart will be wired to Prometheus/Temporal later. + +### Q4: Recent Runs panel — data source? + +The sketch shows a "Recent Runs" list (left bottom panel). The existing `GET /api/sessions` endpoint returns sessions per user. Should "Recent Runs" map to: + +- **(A) Recent sessions** — list of most recent agent chat sessions (what exists today) +- **(B) Recent cron job executions** — from cronjob `lastRunTime`/`lastRunResult` +- **(C) Both combined** — merge sessions + cron job runs into a unified "recent activity" list + +**A4:** Use the kagent database via a new lightweight backend stats endpoint (`GET /api/dashboard/stats`). This endpoint will run COUNT queries and return recent sessions, avoiding the need to fetch full lists client-side. Scope update: this is no longer purely frontend-only — includes a small backend addition. + +### Q5: Live Feed panel — include or stub? + +The sketch shows a "Live Feed" mini-panel (right bottom) with a green dot and "0 events". The `/feed` page is currently a placeholder and there's no system-wide event stream backend. Should we: + +- **(A) Stub with placeholder** — show the panel frame with "Coming soon" or "No events" +- **(B) Omit entirely** — skip the Live Feed panel for now, make Recent Runs take full width +- **(C) Show recent events from sessions** — pull latest session events as a pseudo-feed (not truly live, but shows recent activity) + +**A5:** (C) Show recent session events as a pseudo-feed. Pull latest events from the DB via the stats endpoint. Not truly live/streaming yet, but shows recent activity. + +### Q6: Top bar — "Stream Connected" badge and namespace selector? + +The sketch shows a top bar with namespace selector dropdown, "Stream Connected" status badge, and logout button. The sidebar already has a namespace selector and StatusIndicator. Should the top bar: + +- **(A) Match the sketch exactly** — duplicate namespace selector + add stream status badge + logout in the top bar +- **(B) Simplified** — just show page title ("Dashboard" / subtitle) + a connection status dot in the top bar, since namespace selector is already in the sidebar +- **(C) No top bar** — rely on sidebar for all controls, main content starts with the stats row + +**A6:** (A) Match the sketch. Top bar includes namespace selector dropdown, "Stream Connected" status badge (green dot + wifi icon), and logout/exit button. This duplicates the sidebar namespace selector intentionally for quick access in the main content area. + +### Q7: Navigation change — what happens to the current AgentList at `/`? + +Currently `/` renders AgentList (agent grid). Dashboard will replace it. Where should the agent list move? + +- **(A) `/agents` already exists** — the agents page at `/agents` already renders AgentList, so just replace `/` with Dashboard. No move needed. +- **(B) Keep agent grid as a section within the dashboard** — embed a compact agent overview in the dashboard itself + +**A7:** (A) Just replace `/` with Dashboard. `/agents` already has the agent list — no move needed. + +### Q8: Stat cards — should they be clickable links to their respective pages? + +For example, clicking "My Agents (3)" navigates to `/agents`, clicking "Models (4)" goes to `/models`, etc. + +- **(A) Yes, clickable** — each card links to its resource page +- **(B) No, static display only** + +**A8:** (B) Static display only. Stat cards show counts but are not clickable links. + +### Q9: Auto-refresh / polling? + +Should the dashboard data refresh automatically, or only on page load? + +- **(A) On page load only** — data fetched once when navigating to dashboard +- **(B) Periodic polling** — refresh stats every N seconds (e.g., 30s) +- **(C) Manual refresh** — show a refresh button the user can click + +**A9:** (A) On page load only. No auto-refresh or polling. + +--- + +## Consolidated Requirements + +1. **Page**: New Dashboard page at `/`, replacing current AgentList (which already exists at `/agents`) +2. **Backend**: New `GET /api/dashboard/stats` endpoint — COUNT queries for resources + recent sessions + recent events +3. **Stats Row**: 7 metric cards (Agents, Workflows, Cron Jobs, Models, Tools, MCP Servers, Git Repos) — static, not clickable +4. **Activity Chart**: Install recharts, build combined line+bar chart with mock data. Real data will come from Prometheus/Temporal later. +5. **Recent Runs**: Left bottom panel, list of recent sessions from DB +6. **Live Feed**: Right bottom panel, pseudo-feed from recent session events (not truly live) +7. **Top Bar**: Namespace selector dropdown, "Stream Connected" badge (green dot + wifi icon), logout button +8. **Data Loading**: On page load only, no auto-refresh/polling +9. **Sidebar**: Dashboard nav item already exists at `/` — no changes needed + diff --git a/specs/dashboard-page/research/api-data-sources.md b/specs/dashboard-page/research/api-data-sources.md new file mode 100644 index 000000000..10e243b78 --- /dev/null +++ b/specs/dashboard-page/research/api-data-sources.md @@ -0,0 +1,62 @@ +# API & Data Sources Research + +## Available Endpoints for Dashboard Data + +### Counts (client-side aggregation from list endpoints) +| Resource | Endpoint | Notes | +|----------|----------|-------| +| Agents | `GET /api/agents` | deploymentReady, accepted status | +| Sessions/Runs | `GET /api/sessions` | per-user, with timestamps | +| Tasks | `GET /api/sessions/{id}/tasks` | per-session | +| Tools | `GET /api/tools` | all MCP tools | +| Tool Servers | `GET /api/toolservers` | lastConnected timestamp | +| Models | `GET /api/modelconfigs` | LLM configurations | +| Cron Jobs | `GET /api/cronjobs` | schedule, lastRunTime, nextRunTime | +| Git Repos | `GET /api/gitrepos` | sync status | +| Feedback | `GET /api/feedback` | isPositive, issueType | + +### Missing (would need new backend endpoints) +- **No `/api/dashboard/stats`** — no aggregation endpoint +- **No time-series aggregation** — no hourly/daily bucketing +- **No run duration tracking** — Task model has no duration field +- **No success/failure status on tasks** — no explicit status enum +- **No token usage tracking** — not in DB models + +## Database Models (relevant) + +``` +Session { id, name, user_id, agent_id, created_at, updated_at } + -> Events { id, session_id, user_id, data(JSON), created_at } + -> Tasks { id, session_id, data(JSON), created_at } + +Agent { id, type, config(JSON), created_at } +Tool { id, server_name, group_kind, description } +ToolServer { name, group_kind, last_connected } +Feedback { id, user_id, message_id, is_positive, feedback_text, issue_type } +``` + +## Server Actions (UI fetch functions) +All in `ui/src/app/actions/`: +- `agents.ts` — getAgents(), getAgent() +- `sessions.ts` — getSessionsForAgent(), getSession() +- `tools.ts` — getTools() +- `servers.ts` — tool servers +- `modelConfigs.ts` — model configs +- `models.ts` — LLM models +- `cronjobs.ts` — cron jobs +- `gitrepos.ts` — git repos +- `plugins.ts` — plugins +- `namespaces.ts` — K8s namespaces + +## Strategy for Dashboard Stats + +### Phase 1: Client-side aggregation +- Fetch all list endpoints in parallel +- Count items client-side +- Use session timestamps for "recent runs" list +- Cron job `lastRunTime`/`lastRunResult` for activity + +### Phase 2: Backend stats endpoint +- New `GET /api/dashboard/stats` returning counts + time-series +- Add run duration/status tracking to Task model +- Add hourly bucketed activity data diff --git a/specs/dashboard-page/research/component-patterns.md b/specs/dashboard-page/research/component-patterns.md new file mode 100644 index 000000000..cdffb64bb --- /dev/null +++ b/specs/dashboard-page/research/component-patterns.md @@ -0,0 +1,68 @@ +# UI Component Patterns Research + +## Available Shadcn/UI Components +Key components in `ui/src/components/ui/`: +- **Card** (Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter) +- **Badge** (variants: default, secondary, destructive, outline) +- **Table** (full HTML table with Radix primitives) +- **Tabs** (tab navigation) +- **Progress** (progress bar) +- **Alert** (alert boxes) +- **Dialog/AlertDialog** (modals) +- **Tooltip** (overlays) +- **ScrollArea** (custom scrollbar) +- **Collapsible** (expand/collapse) +- **Separator** (divider) + +## Chart Library +**None installed.** No recharts, chart.js, visx, or similar in package.json. + +However, **5 chart colors are pre-defined** in CSS variables: +- `--chart-1` through `--chart-5` (with dark mode variants) + +**Recommendation:** Install `recharts` (most common with Shadcn/UI) for the Agent Activity chart. + +## Existing Page Patterns + +### Agent Card (`AgentCard.tsx`) +- Card with hover: `group relative transition-all duration-200` +- Status badges: red-500/10, yellow-400/30 +- Dropdown menu for actions +- Responsive grid: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6` + +### Models List +- Expandable rows with inline edit/delete +- Layout: `min-h-screen p-8` > `max-w-6xl mx-auto` + +### Tools Page +- Category grouping with collapsible sections +- Search + filter UI +- ScrollArea with `h-[calc(100vh-300px)]` + +## Theme & Styling + +### Color System (CSS variables) +- `--background/--foreground` — base colors +- `--card/--card-foreground` — card surfaces +- `--primary` — purple-ish accent +- `--destructive` — red for errors +- `--muted` — subdued text +- Dark mode: `darkMode: "class"` with `.dark` selector + +### Sidebar Colors +- `--sidebar-background/foreground/primary/accent/border` + +### Common Layout Classes +- Page: `min-h-screen p-8` +- Container: `max-w-6xl mx-auto` +- Spacing: `space-y-4` +- Responsive grid: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3` + +## Icons +**Lucide React** (v0.562.0) — used throughout. Standard sizes: `h-4 w-4`, `h-5 w-5`. + +## What Needs to Be Built +1. **Stat card component** — Card + icon + count + label (no existing component) +2. **Activity chart** — needs recharts or similar +3. **Recent runs list** — can adapt from existing session/task patterns +4. **Live feed mini-panel** — embed of /feed functionality diff --git a/specs/dashboard-page/research/streaming-infrastructure.md b/specs/dashboard-page/research/streaming-infrastructure.md new file mode 100644 index 000000000..37f6a15df --- /dev/null +++ b/specs/dashboard-page/research/streaming-infrastructure.md @@ -0,0 +1,53 @@ +# Live Feed & Streaming Infrastructure Research + +## Current State +- `/feed` route: **placeholder** ("Coming soon") +- No dedicated live feed backend endpoint +- No event bus or pub/sub for system-wide events + +## Existing SSE Infrastructure (A2A Streaming) + +A fully functional SSE pipeline exists for agent chat: + +``` +Browser (ChatInterface) + -> POST /a2a/{namespace}/{agentName} +Next.js API Route (proxy + keep-alive) + -> POST /a2a/{namespace}/{agentName}/ +Go Backend (A2A Handler Mux) + -> Agent Runtime (Python) + <- SSE events back up pipeline +``` + +### Key Components +| Layer | File | Role | +|-------|------|------| +| SSE Client | `ui/src/lib/a2aClient.ts` | Parse SSE, async iterable | +| Proxy | `ui/src/app/a2a/[ns]/[name]/route.ts` | Keep-alive (30s), stream forwarding | +| Backend | `go/core/internal/a2a/a2a_handler_mux.go` | Request multiplexing | +| Registrar | `go/core/internal/a2a/a2a_registrar.go` | Dynamic handler registration | +| Middleware | `go/core/internal/httpserver/middleware.go` | HTTP Flusher support | + +### Features +- Protocol: SSE (`text/event-stream`) +- Keep-alive: 30s comment events +- Client timeout: 10 minutes +- Cancellation: AbortController +- Flushing: immediate (`FlushInterval: -1`) + +## StatusIndicator +**Not streaming.** Simple HTTP fetch of `/api/plugins` with 3 states: loading, ok, plugins-failed. Has retry button. + +## Implications for Dashboard + +### Live Feed Panel +The dashboard sketch shows a "Live Feed" mini-panel. Options: +1. **Embed session events** — poll `GET /api/sessions` + events periodically +2. **New SSE endpoint** — `GET /api/feed` streaming system events (agent starts, completions, errors) +3. **Reuse A2A infra** — adapt existing SSE patterns for a system-wide event stream + +### "Stream Connected" Badge +The top bar shows "Stream Connected" status. This would need: +- A persistent SSE connection for system events +- Connection state tracking (connected/disconnected/reconnecting) +- Could reuse patterns from A2A keep-alive diff --git a/specs/dashboard-page/research/ui-structure.md b/specs/dashboard-page/research/ui-structure.md new file mode 100644 index 000000000..00a67d00a --- /dev/null +++ b/specs/dashboard-page/research/ui-structure.md @@ -0,0 +1,63 @@ +# UI Structure & Routing Research + +## Current Page Structure (Next.js App Router) + +### Routes +| Route | Status | Description | +|-------|--------|-------------| +| `/` | AgentList (not dashboard) | Currently renders AgentGrid, no metrics | +| `/feed` | Placeholder | "Coming soon" | +| `/plugins` | Active | Plugin status page | +| `/agents` | Active | Agent list with create/edit | +| `/agents/[ns]/[name]/chat` | Active | Chat interface with A2A streaming | +| `/workflows` | Placeholder | "Coming soon" | +| `/cronjobs` | Active | Cron job management | +| `/models` | Active | Model config management | +| `/tools` | Active | Tool library (searchable, filterable) | +| `/servers` | Active | MCP server management | +| `/git` | Active | Git repo management | +| `/admin/org` | Placeholder | "Coming soon" | +| `/admin/gateways` | Placeholder | "Coming soon" | + +### Layout Hierarchy +``` +RootLayout + TooltipProvider > AgentsProvider > NamespaceProvider > ThemeProvider + AppInitializer > SidebarProvider + AppSidebar (left sidebar) + SidebarHeader (logo, theme toggle, namespace selector) + SidebarContent > AppSidebarNav + SidebarFooter > StatusIndicator + SidebarRail (collapse toggle) + SidebarInset (main content) + MobileTopBar + {children} + Toaster (sonner) +``` + +### Key Files +- `ui/src/app/page.tsx` — root page (currently AgentList, needs to become Dashboard) +- `ui/src/app/layout.tsx` — root layout with providers +- `ui/src/components/AgentList.tsx` — current home page component +- `ui/src/components/AgentGrid.tsx` — agent card grid + +## Sidebar Navigation + +**NAV_SECTIONS** in `AppSidebarNav.tsx`: +- **OVERVIEW**: Dashboard (`/`), Live Feed (`/feed`), Plugins (`/plugins`) +- **AGENTS**: My Agents (`/agents`), Workflows (`/workflows`), Cron Jobs (`/cronjobs`) +- **RESOURCES**: Models (`/models`), Tools (`/tools`), MCP Servers (`/servers`), GIT Repos (`/git`) +- **ADMIN**: Organization (`/admin/org`), Gateways (`/admin/gateways`) + +Dashboard nav item already exists pointing to `/`. Just need the actual dashboard page. + +### Plugin Integration +- Plugins injected into nav dynamically via `SidebarStatusProvider` +- Badge updates via custom events: `kagent:plugin-badge` +- Unknown section names create a new "PLUGINS" section + +## Implication for Dashboard +The current `/` page just shows AgentList. To add Dashboard: +1. Replace `page.tsx` at root with Dashboard component +2. Move AgentList to `/agents` route (or keep as sub-component) +3. Dashboard nav item already wired to `/` diff --git a/specs/dashboard-page/rough-idea.md b/specs/dashboard-page/rough-idea.md new file mode 100644 index 000000000..379731044 --- /dev/null +++ b/specs/dashboard-page/rough-idea.md @@ -0,0 +1,87 @@ +# Rough Idea + +Dashboard page - reference screenshot: `image.png` + +## Layout Sketch + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ [browser tabs / top chrome] │ +├──────────────────┬──────────────────────────────────────────────────────────┤ +│ [K] KAgent 🌙 │ Namespace: [default ▼] ● Stream Connected [→] │ +│ [default ▼] │ │ +│ │ Dashboard │ +│ OVERVIEW │ Overview of your KAgent cluster │ +│ ⊞ Dashboard │ │ +│ ∿ Live Feed │ ┌──────────────────────────────────────────────────────┐│ +│ 🧩 Plugins │ │ 🤖 MY AGENTS ⑂ WORKFLOWS ⏱ CRON JOBS 🧠 MODELS ││ +│ │ │ 3 0 3 4 ││ +│ AGENTS │ │ ││ +│ 🤖 My Agents │ │ 🔧 TOOLS 🖥 MCP SERVERS ⑂ GIT REPOS ││ +│ ⑂ Workflows │ │ 3 2 0 ││ +│ ⏱ Cron Jobs │ └──────────────────────────────────────────────────────┘│ +│ │ │ +│ RESOURCES │ ┌──────────────────────────────────────────────────────┐│ +│ 🧠 Models │ │ Agent Activity [Avg] P95 1h [24hr] 7d││ +│ 🔧 Tools │ │ Runs over time with failed runs highlighted ││ +│ 🖥 MCP Servers │ │ ││ +│ ⑂ GIT Repos │ │ Total runs: 47 Avg duration: 51.0s ││ +│ │ │ Failed runs: 39 Failure rate: 83.0% ││ +│ ADMIN │ │ ││ +│ 🏢 Organization │ │ ^ (line chart + bar chart combined) ││ +│ 🌐 Gateways │ │ | /\ ││ +│ │ │ | / \ /\ ■■ /\ ││ +│ │ │ |______/____\______/ \_/ \/ \_________ ││ +│ │ │ 9p 12a 3a 6a 9a 12p 3p 6p 9p ││ +│ │ │ ││ +│ [status footer] │ │ ● Avg run duration ● Agents installed (bars) ││ +│ │ │ ● Failed buckets ││ +│ │ └──────────────────────────────────────────────────────┘│ +│ │ │ +│ │ ┌─────────────────────────┐ ┌─────────────────────┐ │ +│ │ │ Recent Runs View all → │ │ ∿ Live Feed ● │ │ +│ │ │ │ │ 0 events│ │ +│ │ │ (list of recent runs) │ │ (live event feed) │ │ +│ │ └─────────────────────────┘ └─────────────────────┘ │ +└──────────────────┴──────────────────────────────────────────────────────────┘ +``` + +## Key UI Elements + +### Sidebar (left, dark) +- Header: KAgent logo + "KAgent" label + theme toggle (🌙/☀) +- Namespace selector dropdown (e.g. "default") — inside sidebar header +- Nav sections and items (from `AppSidebarNav`): + - **OVERVIEW**: Dashboard, Live Feed, Plugins + - **AGENTS**: My Agents, Workflows, Cron Jobs + - **RESOURCES**: Models, Tools, MCP Servers, GIT Repos + - **ADMIN**: Organization, Gateways + - *(dynamic)* **PLUGINS**: any plugin-registered nav items appended here +- Footer: `StatusIndicator` component (connection/stream status) +- Collapsible to icon-only mode + +### Top Bar (main content area) +- Page title: "Dashboard" / subtitle: "Overview of your KAgent cluster" +- Connection status badge: "Stream Connected" (green dot + wifi icon) — top right +- Logout/exit button (top right) + +### Stats Row (summary cards) +Six metric cards in a horizontal row (mapped to KAgent resources): +1. My Agents — 3 +2. Workflows — 0 +3. Cron Jobs — 3 +4. Models — 4 +5. Tools — 3 +6. MCP Servers — 2 + +### Agent Activity Chart +- Title: "Agent Activity" with subtitle "Runs over time with failed runs highlighted" +- Time range toggle: Avg | P95 | 1h | **24hr** (active) | 7 days +- Summary stats: Total runs, Avg duration (cyan), Failed runs (red), Failure rate +- Combined chart: line (avg run duration) + bar (agents installed) + failed buckets highlighted in teal/green +- X-axis: time labels (9p, 12a, 3a, 6a, 9a, 12p, 3p, 6p, 9p) +- Legend: Avg run duration (blue line), Agents installed (bars, teal), Failed buckets (red dots) + +### Bottom Row (two panels) +- **Recent Runs** (left half): list of recent agent runs with "View all →" link +- **Live Feed** (right half): live event feed (replaces "Event Stream"), green dot indicator, shows "0 events" — maps to `/feed` route diff --git a/specs/dashboard-page/summary.md b/specs/dashboard-page/summary.md new file mode 100644 index 000000000..bd9e5775d --- /dev/null +++ b/specs/dashboard-page/summary.md @@ -0,0 +1,34 @@ +# Dashboard Page — Summary + +## Artifacts + +| File | Description | +|------|-------------| +| `specs/dashboard-page/rough-idea.md` | Original idea with ASCII layout sketch | +| `specs/dashboard-page/requirements.md` | 9 Q&A decisions defining scope | +| `specs/dashboard-page/research/ui-structure.md` | Current UI pages, routing, layout | +| `specs/dashboard-page/research/api-data-sources.md` | Available API endpoints and DB models | +| `specs/dashboard-page/research/component-patterns.md` | Shadcn/UI components, styling, theme | +| `specs/dashboard-page/research/streaming-infrastructure.md` | SSE/streaming and live feed status | +| `specs/dashboard-page/design.md` | Detailed design with architecture, components, acceptance criteria | +| `specs/dashboard-page/plan.md` | 13-step implementation plan with checklist | + +## Overview + +A new Dashboard page at `/` replacing the current AgentList. Shows 7 resource stat cards, a recharts activity chart (mock data, Prometheus/Temporal later), recent runs from DB sessions, and a pseudo-live feed from recent events. Includes a small Go backend endpoint (`GET /api/dashboard/stats`) for aggregated counts and recent data. + +## Key Decisions + +- **Scope:** Frontend + small backend stats endpoint (not purely frontend-only) +- **Stats:** 7 cards for all resources, static (not clickable) +- **Chart:** recharts with mock data, real data from Prometheus/Temporal in future +- **Data:** New `GET /api/dashboard/stats` endpoint with DB COUNT queries +- **Live Feed:** Pseudo-feed from recent session events (not true streaming) +- **Top Bar:** Namespace selector + "Stream Connected" badge + logout +- **Refresh:** On page load only, no polling + +## Suggested Next Steps + +1. Review and approve the design and plan +2. Implement via the 13-step plan (backend steps 1-4, frontend steps 5-12, integration step 13) +3. Future: wire activity chart to Prometheus/Temporal, add real SSE live feed, add auto-refresh diff --git a/specs/dynamic-mcp-ui-routing/PROMPT.md b/specs/dynamic-mcp-ui-routing/PROMPT.md new file mode 100644 index 000000000..a9ee522a4 --- /dev/null +++ b/specs/dynamic-mcp-ui-routing/PROMPT.md @@ -0,0 +1,151 @@ +# PROMPT: Dynamic MCP UI Routing for Plugins + +## Objective + +Implement dynamic UI routing for MCP plugins in kagent. MCP tool servers declare UI metadata in their RemoteMCPServer CRD. The Go backend discovers these declarations, persists them, and reverse-proxies plugin UIs at `/_p/{name}/`. The Next.js UI renders plugins in sandboxed iframes at `/plugins/{name}` with a postMessage bridge. Migrate the existing kanban integration to this system. + +## Architecture + +``` +Browser URL: /plugins/{name} → nginx location / → Next.js (sidebar + iframe shell) +Iframe src: /_p/{name}/ → nginx location /_p/ → Go backend → upstream plugin service +API: /api/plugins → nginx location /api/ → Go backend → database +``` + +Key: browser URLs and internal proxy URLs use separate paths to avoid nginx routing conflicts. + +## Implementation Steps + +### Backend (Go) + +1. **CRD** — Add `PluginUISpec` to `go/api/v1alpha2/remotemcpserver_types.go`: + - Fields: `Enabled` bool, `PathPrefix` string, `DisplayName` string, `Icon` string, `Section` enum + - Optional `UI *PluginUISpec` on `RemoteMCPServerSpec` + - Run `make -C go generate` + +2. **Database** — Add `Plugin` model to `go/api/database/models.go`: + - PK: `Name` (namespace/name), unique index: `PathPrefix` + - Fields: `DisplayName`, `Icon`, `Section`, `UpstreamURL` + - Interface methods: `StorePlugin`, `DeletePlugin`, `GetPluginByPathPrefix`, `ListPlugins` + +3. **Controller** — Extend `go/core/internal/controller/reconciler/reconciler.go`: + - `reconcilePluginUI(server)` — upsert/delete Plugin records from CRD `spec.ui` + - `deriveBaseURL(url)` — strip path from `spec.url` to get upstream base + - Non-fatal: plugin UI failure must not block tool discovery + +4. **API handler** — `go/core/internal/httpserver/handlers/plugins.go`: + - `GET /api/plugins` — returns `[]PluginResponse{name, pathPrefix, displayName, icon, section}` + +5. **Proxy handler** — `go/core/internal/httpserver/handlers/pluginproxy.go`: + - `/_p/{name}/{path...}` — DB lookup by pathPrefix, reverse proxy to upstream + - Strip `/_p/{name}` prefix before forwarding + - `sync.Map` cache for proxy instances, `FlushInterval: -1` for SSE + +6. **Routes** — `go/core/internal/httpserver/server.go`: + - `GET /api/plugins` → `PluginsHandler.HandleListPlugins` + - `PathPrefix("/_p/{name}")` → `PluginProxyHandler.HandleProxy` + +### Nginx + +7. **`ui/conf/nginx.conf`**: + - Add `location /_p/` → `proxy_pass http://kagent_backend/_p/;` (buffering off, WebSocket headers) + - Remove any hardcoded `/kanban-mcp/` block + - Do NOT add `location /plugins/` — browser URLs must reach Next.js via `location /` + +### Frontend (Next.js) + +8. **Plugin page** — `ui/src/app/plugins/[name]/[[...path]]/page.tsx`: + - iframe with `src=/_p/${name}/${subPath}` (NOT `/plugins/`) + - `sandbox="allow-scripts allow-same-origin allow-forms allow-popups"` + - postMessage bridge: handle `kagent:ready`, `kagent:navigate`, `kagent:resize`, `kagent:badge`, `kagent:title` + - Send `kagent:context` (theme, namespace, authToken) on load and on changes + - Loading skeleton while iframe loads (`onLoad` handler) + - "Plugin unavailable" fallback with retry on `onError` + +9. **Sidebar** — `ui/src/components/sidebars/AppSidebarNav.tsx`: + - Fetch `/api/plugins` on mount, merge into nav sections by `section` field + - Loading indicator while fetch in-flight + - Error indicator with retry button on fetch failure (NOT silent `.catch(() => {})`) + - Badge support via `kagent:plugin-badge` custom event listener + - `getIconByName(kebab-case)` → lucide-react component, fallback to Puzzle + +10. **Plugin bridge SDK** — `go/plugins/kagent-plugin-bridge.js`: + - `connect()`, `onContext(fn)`, `navigate(href)`, `setBadge(count, label)`, `setTitle(title)`, `reportHeight(height)` + +### Migration + +11. **Kanban** — Add `ui` section to kanban-mcp Helm RemoteMCPServer template: + - `enabled: true, pathPrefix: "kanban", displayName: "Kanban Board", icon: "kanban", section: "AGENTS"` + - Delete `ui/src/app/kanban/page.tsx`, remove static sidebar entry + - Integrate `kagent-plugin-bridge.js` in kanban-mcp embedded UI + +### Testing + +12. **Go unit tests**: + - `deriveBaseURL()` with various URL formats + - `PluginsHandler` returns correct JSON shape + - `PluginProxyHandler` strips `/_p/` prefix, 404 on unknown, proxy cache reuse + - Controller `reconcilePluginUI` create/update/delete/defaults + +13. **Go E2E test** — `go/core/test/e2e/plugin_routing_test.go`: + - Create RemoteMCPServer with `ui` → poll `/api/plugins` → verify metadata + - Verify `/_p/{name}/` returns proxied response (non-404) + - Delete CRD → verify removed from `/api/plugins` and `/_p/` returns 404 + +14. **Frontend unit tests** — `ui/src/components/sidebars/__tests__/AppSidebarNav.test.tsx`: + - Plugin items merged into correct sections + - Badge renders on `kagent:plugin-badge` event + - Loading state during fetch, error state on failure, retry re-fetches + +15. **Mock plugin service** — `ui/e2e/fixtures/`: + - K8s Deployment + Service + RemoteMCPServer CRD with `ui` section + - HTML with inline bridge: receives `kagent:context`, sends `kagent:badge {count: 3}` + - `data-testid` attributes for Playwright selectors + +16. **Playwright browser E2E** — `ui/e2e/plugin-routing.spec.ts`: + 1. Sidebar shows plugin nav item from `/api/plugins` + 2. Click navigates to `/plugins/{name}` with sidebar + iframe + 3. Hard refresh on `/plugins/{name}` preserves sidebar layout + 4. Theme sync via postMessage (iframe receives `kagent:context`) + 5. Badge update appears in sidebar + 6. Loading state shown (no error on success) + 7. Error state + retry button for unreachable plugin + +17. **CI integration**: + - `scripts/check-plugins-api.sh` — add `--wait` polling mode, `--proxy` check for `/_p/` + - Makefile: `test-e2e-browser` (Playwright), `test-e2e-all` (Go E2E + API check + Playwright) + +## Acceptance Criteria + +```gherkin +# Routing +Given nginx has location /_p/ (Go backend) and location / (Next.js) +When a user navigates to /plugins/kanban (browser URL) +Then Next.js renders sidebar + iframe with src=/_p/kanban/ +And hard refresh preserves the same layout + +# API Pipeline +Given a RemoteMCPServer CRD with ui.enabled=true and ui.pathPrefix="kanban" +When the controller reconciles +Then GET /api/plugins returns the plugin metadata +And GET /_p/kanban/ reverse-proxies to the plugin service + +# UI +Given /api/plugins returns plugin metadata +Then the sidebar shows the plugin in the correct section with icon and badge +And the plugin page renders iframe content with postMessage bridge + +# Error Handling +Given /api/plugins fails or upstream is unreachable +Then the UI shows loading/error states with retry (not silent empty) + +# Testing +Given Playwright tests run against Kind cluster with mock plugin +Then all 7 browser E2E scenarios pass +``` + +## Reference + +- Design: `specs/dynamic-mcp-ui-routing/design.md` +- Plan (17 steps): `specs/dynamic-mcp-ui-routing/plan.md` +- Requirements (Q1-Q13): `specs/dynamic-mcp-ui-routing/requirements.md` diff --git a/specs/dynamic-mcp-ui-routing/design.md b/specs/dynamic-mcp-ui-routing/design.md new file mode 100644 index 000000000..f6575c461 --- /dev/null +++ b/specs/dynamic-mcp-ui-routing/design.md @@ -0,0 +1,1167 @@ +# Design: Dynamic MCP UI Routing for Plugins + +## Overview + +Replace the hardcoded nginx proxy rules and static Next.js routes for MCP plugin UIs with a fully dynamic system. MCP tool servers declare UI metadata in their RemoteMCPServer CRD. The Go backend discovers these declarations, persists them to the database, and serves as a reverse proxy at `/plugins/{name}/`. The Next.js UI renders plugin UIs in sandboxed iframes with a postMessage bridge for theme sync, resize, navigation, namespace context, auth forwarding, and badge updates. The existing kanban integration migrates to this new system as proof-of-concept. + +--- + +## Detailed Requirements + +*(Consolidated from requirements.md)* + +### Architecture Decisions +- **Proxy**: Go reverse proxy handles `/plugins/{name}/` routing dynamically (not nginx per-plugin) +- **Metadata**: Extend RemoteMCPServer CRD with optional `ui` section +- **UI URL**: Derived from existing `spec.url` (strip MCP path to get base URL) +- **Sidebar**: Configurable `ui.section` field, default "PLUGINS" +- **Rendering**: iframe with postMessage bridge (CSS/JS isolation, MCP Apps-aligned) +- **Migration**: Existing kanban moves to new plugin system +- **Path**: `/plugins/{name}/` top-level (one-time nginx `location /plugins/` addition) +- **Discovery API**: New `/api/plugins` endpoint + +### postMessage Bridge (all v1) +1. Theme sync (light/dark + CSS variables) +2. Resize/height (iframe auto-fills or reports content height) +3. Navigation events (plugin triggers host navigation) +4. Namespace context (host sends active namespace) +5. Auth token forwarding (host passes auth context) +6. Title/badge updates (plugin updates sidebar badge dynamically) + +--- + +## Architecture Overview + +**Key routing principle:** Browser URLs (`/plugins/{name}`) go to Next.js for the shell page with sidebar. Internal proxy URLs (`/_p/{name}/`) go to Go backend for reverse-proxying to upstream plugin services. This separation prevents nginx routing conflicts. + +```mermaid +graph TD + Browser[Browser] -->|/plugins/kanban| Nginx + Browser -->|/api/plugins| Nginx + Browser -->|iframe: /_p/kanban/| Nginx + + Nginx -->|"location /_p/"| GoBackend[Go Backend] + Nginx -->|"location /api/"| GoBackend + Nginx -->|"location /"| NextJS[Next.js UI] + + GoBackend -->|reverse proxy| PluginA[kanban-mcp Service] + GoBackend -->|reverse proxy| PluginB[gitrepo-mcp Service] + GoBackend -->|reverse proxy| PluginN[plugin-N Service] + + GoBackend -->|read plugin metadata| DB[(Database)] + + Controller[K8s Controller] -->|watch RemoteMCPServer| K8sAPI[K8s API] + Controller -->|persist UI metadata| DB + + NextJS -->|fetch /api/plugins| GoBackend + NextJS -->|"render iframe src=/_p/name/"| Browser + + subgraph "iframe sandbox" + PluginUI[Plugin UI HTML/JS] + end + + Browser -->|postMessage bridge| PluginUI +``` + +### Request Flow + +```mermaid +sequenceDiagram + participant B as Browser + participant N as Nginx + participant UI as Next.js + participant Go as Go Backend + participant DB as Database + participant P as Plugin Service + + Note over B,P: Page Load (browser URL) + B->>N: GET /plugins/kanban + N->>UI: location / → proxy to Next.js + UI->>B: Render sidebar + iframe shell page + + Note over B,P: Sidebar Discovery + B->>N: GET /api/plugins + N->>Go: location /api/ → proxy to backend + Go->>DB: query plugins with ui.enabled=true + DB-->>Go: plugin list with UI metadata + Go-->>B: [{name, displayName, icon, section, pathPrefix}] + + Note over B,P: Plugin UI Load (internal proxy URL via iframe) + B->>N: GET /_p/kanban/ (iframe src) + N->>Go: location /_p/ → proxy to backend + Go->>DB: lookup plugin "kanban" → service URL + Go->>P: reverse proxy request + P-->>Go: HTML response + Go-->>B: plugin UI HTML inside iframe + + Note over B,P: postMessage Bridge + B->>B: host sends theme, namespace, auth to iframe + B->>B: iframe sends badge update, nav event to host +``` + +--- + +## Components and Interfaces + +### 1. CRD Extension: RemoteMCPServerSpec.UI + +**File:** `go/api/v1alpha2/remotemcpserver_types.go` + +```go +// PluginUISpec defines optional UI metadata for MCP servers that provide a web interface. +type PluginUISpec struct { + // Enabled indicates this MCP server provides a web UI. + // +optional + // +kubebuilder:default=false + Enabled bool `json:"enabled,omitempty"` + + // PathPrefix is the URL path segment used for routing: /plugins/{pathPrefix}/ + // Must be a valid URL path segment (lowercase alphanumeric + hyphens). + // Defaults to the RemoteMCPServer name if not specified. + // +optional + // +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*[a-z0-9]$` + // +kubebuilder:validation:MaxLength=63 + PathPrefix string `json:"pathPrefix,omitempty"` + + // DisplayName is the human-readable name shown in the sidebar. + // Defaults to the RemoteMCPServer name if not specified. + // +optional + DisplayName string `json:"displayName,omitempty"` + + // Icon is a lucide-react icon name (e.g., "kanban", "git-fork", "database"). + // +optional + // +kubebuilder:default="puzzle" + Icon string `json:"icon,omitempty"` + + // Section is the sidebar section where this plugin appears. + // +optional + // +kubebuilder:default="PLUGINS" + // +kubebuilder:validation:Enum=OVERVIEW;AGENTS;RESOURCES;ADMIN;PLUGINS + Section string `json:"section,omitempty"` +} + +type RemoteMCPServerSpec struct { + // ... existing fields ... + + // UI defines optional web UI metadata for this MCP server. + // When ui.enabled is true, the server's UI is accessible at /plugins/{ui.pathPrefix}/ + // +optional + UI *PluginUISpec `json:"ui,omitempty"` +} +``` + +**Example CRD:** +```yaml +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: kanban-mcp + namespace: kagent +spec: + description: Kanban task board MCP server + protocol: STREAMABLE_HTTP + url: http://kanban-mcp.kagent.svc.cluster.local:8080/mcp + ui: + enabled: true + pathPrefix: "kanban" + displayName: "Kanban Board" + icon: "kanban" + section: "AGENTS" +``` + +### 2. Database Model: Plugin + +**File:** `go/api/database/models.go` + +```go +// Plugin represents an MCP server that provides a web UI. +// Populated by the controller from RemoteMCPServer CRDs with ui.enabled=true. +type Plugin struct { + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + // Name is the RemoteMCPServer ref (namespace/name format) + Name string `gorm:"primaryKey;not null" json:"name"` + // PathPrefix is the URL routing segment + PathPrefix string `gorm:"uniqueIndex;not null" json:"path_prefix"` + // DisplayName for sidebar + DisplayName string `json:"display_name"` + // Icon is the lucide-react icon name + Icon string `json:"icon"` + // Section is the sidebar section + Section string `json:"section"` + // UpstreamURL is the base URL to proxy to (derived from spec.url) + UpstreamURL string `json:"upstream_url"` +} + +func (Plugin) TableName() string { return "plugin" } +``` + +### 3. Database Client Interface Extension + +**File:** `go/api/database/client.go` + +Add to `Client` interface: + +```go +// Plugin methods +StorePlugin(plugin *Plugin) (*Plugin, error) +DeletePlugin(name string) error +GetPluginByPathPrefix(pathPrefix string) (*Plugin, error) +ListPlugins() ([]Plugin, error) +``` + +### 4. Controller: Reconciler Extension + +**File:** `go/core/internal/controller/reconciler/reconciler.go` + +Extend `ReconcileKagentRemoteMCPServer()` to handle UI metadata: + +```go +// After existing tool server upsert (line ~430), add: +if err := a.reconcilePluginUI(ctx, server); err != nil { + log.Error(err, "failed to reconcile plugin UI", "server", serverRef) + // Non-fatal: plugin UI failure should not block tool discovery +} +``` + +```go +func (a *kagentReconciler) reconcilePluginUI( + ctx context.Context, + server *v1alpha2.RemoteMCPServer, +) error { + serverRef := fmt.Sprintf("%s/%s", server.Namespace, server.Name) + + // If UI not enabled, ensure plugin record is deleted + if server.Spec.UI == nil || !server.Spec.UI.Enabled { + return a.dbClient.DeletePlugin(serverRef) + } + + ui := server.Spec.UI + + // Derive upstream URL from spec.url (strip path to get base) + upstreamURL, err := deriveBaseURL(server.Spec.URL) + if err != nil { + return fmt.Errorf("failed to derive upstream URL: %w", err) + } + + // Derive defaults + pathPrefix := ui.PathPrefix + if pathPrefix == "" { + pathPrefix = server.Name + } + displayName := ui.DisplayName + if displayName == "" { + displayName = server.Name + } + icon := ui.Icon + if icon == "" { + icon = "puzzle" + } + section := ui.Section + if section == "" { + section = "PLUGINS" + } + + plugin := &database.Plugin{ + Name: serverRef, + PathPrefix: pathPrefix, + DisplayName: displayName, + Icon: icon, + Section: section, + UpstreamURL: upstreamURL, + } + + _, err = a.dbClient.StorePlugin(plugin) + return err +} + +// deriveBaseURL strips the path from a URL to get the base (scheme + host). +// e.g., "http://kanban-mcp.kagent.svc:8080/mcp" → "http://kanban-mcp.kagent.svc:8080" +func deriveBaseURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + u.Path = "" + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} +``` + +On deletion (when CR is not found), add plugin cleanup alongside existing tool server deletion: + +```go +// Existing: delete tool server and tools +// Add: delete plugin +_ = a.dbClient.DeletePlugin(serverRef) +``` + +### 5. HTTP Handler: PluginsHandler + +**File:** `go/core/internal/httpserver/handlers/plugins.go` (new) + +```go +package handlers + +// PluginsHandler handles plugin-related requests +type PluginsHandler struct { + *Base +} + +func NewPluginsHandler(base *Base) *PluginsHandler { + return &PluginsHandler{Base: base} +} + +// HandleListPlugins handles GET /api/plugins — returns all plugins with UI metadata +func (h *PluginsHandler) HandleListPlugins(w ErrorResponseWriter, r *http.Request) { + plugins, err := h.DatabaseService.ListPlugins() + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to list plugins", err)) + return + } + + resp := make([]PluginResponse, len(plugins)) + for i, p := range plugins { + resp[i] = PluginResponse{ + Name: p.Name, + PathPrefix: p.PathPrefix, + DisplayName: p.DisplayName, + Icon: p.Icon, + Section: p.Section, + } + } + + data := api.NewResponse(resp, "Successfully listed plugins", false) + RespondWithJSON(w, http.StatusOK, data) +} + +type PluginResponse struct { + Name string `json:"name"` + PathPrefix string `json:"pathPrefix"` + DisplayName string `json:"displayName"` + Icon string `json:"icon"` + Section string `json:"section"` +} +``` + +### 6. HTTP Handler: Plugin Reverse Proxy + +**File:** `go/core/internal/httpserver/handlers/pluginproxy.go` (new) + +```go +package handlers + +// PluginProxyHandler handles /plugins/{name}/ reverse proxy requests +type PluginProxyHandler struct { + *Base + // Cache of pathPrefix → *httputil.ReverseProxy to avoid recreating per-request + proxies sync.Map +} + +func NewPluginProxyHandler(base *Base) *PluginProxyHandler { + return &PluginProxyHandler{Base: base} +} + +// HandleProxy handles all requests to /_p/{name}/{path...} +func (h *PluginProxyHandler) HandleProxy(w http.ResponseWriter, r *http.Request) { + pathPrefix := mux.Vars(r)["name"] + if pathPrefix == "" { + http.Error(w, "plugin name required", http.StatusBadRequest) + return + } + + plugin, err := h.DatabaseService.GetPluginByPathPrefix(pathPrefix) + if err != nil { + http.Error(w, "plugin not found", http.StatusNotFound) + return + } + + proxy := h.getOrCreateProxy(plugin) + + // Strip the /_p/{name} prefix before forwarding + originalPath := r.URL.Path + prefix := "/_p/" + pathPrefix + r.URL.Path = strings.TrimPrefix(originalPath, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + + proxy.ServeHTTP(w, r) +} + +func (h *PluginProxyHandler) getOrCreateProxy(plugin *database.Plugin) *httputil.ReverseProxy { + if cached, ok := h.proxies.Load(plugin.PathPrefix); ok { + return cached.(*httputil.ReverseProxy) + } + + target, _ := url.Parse(plugin.UpstreamURL) + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Header.Set("X-Forwarded-Host", req.Host) + req.Header.Set("X-Plugin-Name", plugin.PathPrefix) + }, + // Flush immediately for SSE support + FlushInterval: -1, + } + + h.proxies.Store(plugin.PathPrefix, proxy) + return proxy +} + +// InvalidateCache removes a cached proxy (called when plugin is updated/deleted) +func (h *PluginProxyHandler) InvalidateCache(pathPrefix string) { + h.proxies.Delete(pathPrefix) +} +``` + +### 7. Route Registration + +**File:** `go/core/internal/httpserver/server.go` + +Add to path constants: + +```go +const ( + // ... existing ... + APIPathPlugins = "/api/plugins" + PluginsProxyPath = "/_p/{name}" +) +``` + +Add to `setupRoutes()`: + +```go +// Plugin discovery API +s.router.HandleFunc(APIPathPlugins, + adaptHandler(s.handlers.Plugins.HandleListPlugins)).Methods(http.MethodGet) + +// Plugin reverse proxy at /_p/{name} (internal path, NOT /plugins/) +// Browser URLs /plugins/{name} go to Next.js via location / catch-all +// Uses raw http.HandlerFunc, not adaptHandler, because it proxies directly +s.router.PathPrefix("/_p/{name}").HandlerFunc( + s.handlers.PluginProxy.HandleProxy) +``` + +### 8. Nginx: Routing Fix — Separate Browser and Proxy Paths + +**File:** `ui/conf/nginx.conf` + +**Critical:** Browser URL `/plugins/{name}` must reach Next.js (for sidebar + iframe shell). +Internal proxy URL `/_p/{name}/` must reach Go backend (for iframe content). + +Replace the hardcoded `/kanban-mcp/` block with `/_p/` (NOT `/plugins/`): + +```nginx +# Internal plugin proxy — iframe content loads via /_p/{name}/ +# Browser URLs /plugins/{name} fall through to location / (Next.js) +location /_p/ { + proxy_pass http://kagent_backend/_p/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_buffering off; +} +``` + +Remove the hardcoded `/kanban-mcp/` location block. +Remove any existing `location /plugins/` block (it would intercept browser URLs). + +### 9. Next.js: Dynamic Plugin Page + +**File:** `ui/src/app/plugins/[name]/[[...path]]/page.tsx` (new) + +```tsx +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import { useNamespace } from "@/lib/namespace-context"; + +// postMessage bridge protocol +interface PluginMessage { + type: string; + payload: unknown; +} + +interface BadgeUpdate { + count?: number; + label?: string; +} + +export default function PluginPage() { + const { name } = useParams<{ name: string }>(); + const { theme, resolvedTheme } = useTheme(); + const { namespace } = useNamespace(); + const iframeRef = useRef(null); + const [title, setTitle] = useState(""); + + // Build iframe src — uses /_p/ internal proxy path (NOT /plugins/) + // /plugins/{name} = browser URL (Next.js page with sidebar) + // /_p/{name}/ = internal proxy URL (Go backend → upstream service) + const path = useParams<{ path?: string[] }>().path; + const subPath = path ? "/" + path.join("/") : "/"; + const iframeSrc = `/_p/${name}${subPath}`; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + // Send context to iframe on changes + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + + const msg: PluginMessage = { + type: "kagent:context", + payload: { + theme: resolvedTheme, + namespace, + // auth token placeholder — populated when auth is implemented + authToken: null, + }, + }; + iframe.contentWindow.postMessage(msg, "*"); + }, [resolvedTheme, namespace]); + + // Listen for messages from iframe + useEffect(() => { + const handler = (event: MessageEvent) => { + if (!event.data?.type?.startsWith("kagent:")) return; + + switch (event.data.type) { + case "kagent:navigate": { + const { href } = event.data.payload as { href: string }; + window.location.href = href; + break; + } + case "kagent:resize": { + const { height } = event.data.payload as { height: number }; + if (iframeRef.current && height > 0) { + iframeRef.current.style.height = `${height}px`; + } + break; + } + case "kagent:badge": { + // Dispatch custom event for sidebar to pick up + const badge = event.data.payload as BadgeUpdate; + window.dispatchEvent( + new CustomEvent("kagent:plugin-badge", { + detail: { plugin: name, ...badge }, + }) + ); + break; + } + case "kagent:title": { + const { title: newTitle } = event.data.payload as { title: string }; + setTitle(newTitle); + break; + } + case "kagent:ready": { + // Plugin loaded — send initial context + iframeRef.current?.contentWindow?.postMessage( + { + type: "kagent:context", + payload: { + theme: resolvedTheme, + namespace, + authToken: null, + }, + } satisfies PluginMessage, + "*" + ); + break; + } + } + }; + + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [name, resolvedTheme, namespace]); + + return ( +
+ {title && ( +
+

{title}

+
+ )} + {loading && ( +
+ + Loading plugin... +
+ )} + {error && ( +
+ +

Plugin unavailable

+ +
+ )} +