From e5b07cc1d80ae6d2f40e827ad9222b507eed4b7f Mon Sep 17 00:00:00 2001 From: Steven Hooker Date: Wed, 18 Feb 2026 15:56:32 +0100 Subject: [PATCH] initial agent-mgr: app builder platform MVP Go API server + Preact UI + Claude Code adapter. - App-centric model (ideas, not repos) - AgentProvider interface for multi-agent support - K8s pod lifecycle for sandboxed agent sessions - Gitea integration (create repos, push branches) - WebSocket streaming for live session output - Woodpecker CI/CD pipelines (kaniko build + kubectl deploy) --- .gitignore | 5 + .woodpecker/build.yaml | 38 ++++ .woodpecker/deploy.yaml | 16 ++ Makefile | 32 +++ cmd/agent-mgr/main.go | 129 +++++++++++ docker/Dockerfile | 23 ++ docker/Dockerfile.claude-runner | 17 ++ docker/entrypoint.sh | 16 ++ go.mod | 62 ++++++ go.sum | 166 ++++++++++++++ internal/api/apps.go | 105 +++++++++ internal/api/router.go | 77 +++++++ internal/api/sessions.go | 136 ++++++++++++ internal/api/ws.go | 74 +++++++ internal/app/service.go | 170 +++++++++++++++ internal/events/valkey.go | 71 ++++++ internal/gitea/client.go | 142 ++++++++++++ internal/provider/claudecode/adapter.go | 127 +++++++++++ internal/provider/claudecode/pod.go | 122 +++++++++++ internal/provider/claudecode/progress.go | 75 +++++++ internal/provider/provider.go | 55 +++++ internal/provider/registry.go | 31 +++ internal/store/migrations/001_init.sql | 29 +++ internal/store/postgres.go | 202 +++++++++++++++++ ui/dist/index.html | 24 +++ ui/embed.go | 17 ++ ui/package.json | 16 ++ ui/src/api.ts | 97 +++++++++ ui/src/components/app-card.tsx | 77 +++++++ ui/src/components/chat.tsx | 121 +++++++++++ ui/src/components/nav.tsx | 44 ++++ ui/src/components/progress.tsx | 55 +++++ ui/src/components/prompt-input.tsx | 77 +++++++ ui/src/index.tsx | 21 ++ ui/src/pages/app-detail.tsx | 264 +++++++++++++++++++++++ ui/src/pages/apps.tsx | 73 +++++++ ui/src/pages/building.tsx | 191 ++++++++++++++++ ui/src/pages/home.tsx | 109 ++++++++++ ui/tsconfig.json | 14 ++ 39 files changed, 3120 insertions(+) create mode 100644 .gitignore create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/deploy.yaml create mode 100644 Makefile create mode 100644 cmd/agent-mgr/main.go create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.claude-runner create mode 100644 docker/entrypoint.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/apps.go create mode 100644 internal/api/router.go create mode 100644 internal/api/sessions.go create mode 100644 internal/api/ws.go create mode 100644 internal/app/service.go create mode 100644 internal/events/valkey.go create mode 100644 internal/gitea/client.go create mode 100644 internal/provider/claudecode/adapter.go create mode 100644 internal/provider/claudecode/pod.go create mode 100644 internal/provider/claudecode/progress.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/registry.go create mode 100644 internal/store/migrations/001_init.sql create mode 100644 internal/store/postgres.go create mode 100644 ui/dist/index.html create mode 100644 ui/embed.go create mode 100644 ui/package.json create mode 100644 ui/src/api.ts create mode 100644 ui/src/components/app-card.tsx create mode 100644 ui/src/components/chat.tsx create mode 100644 ui/src/components/nav.tsx create mode 100644 ui/src/components/progress.tsx create mode 100644 ui/src/components/prompt-input.tsx create mode 100644 ui/src/index.tsx create mode 100644 ui/src/pages/app-detail.tsx create mode 100644 ui/src/pages/apps.tsx create mode 100644 ui/src/pages/building.tsx create mode 100644 ui/src/pages/home.tsx create mode 100644 ui/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df3d99f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +ui/node_modules/ +ui/dist/index.js +*.secret.tfvars +.env diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..6297b20 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,38 @@ +variables: + - &kaniko_settings + registry: git.asp.now + username: + from_secret: gitea_user + password: + from_secret: gitea_token + +when: + - event: [push, tag] + branch: main + +steps: + - name: build-agent-mgr + image: woodpeckerci/plugin-kaniko + settings: + <<: *kaniko_settings + repo: git.asp.now/platform/agent-mgr + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + dockerfile: docker/Dockerfile + context: . + cache: true + cache_repo: git.asp.now/platform/agent-mgr/cache + + - name: build-claude-runner + image: woodpeckerci/plugin-kaniko + settings: + <<: *kaniko_settings + repo: git.asp.now/platform/claude-code-runner + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + dockerfile: docker/Dockerfile.claude-runner + context: docker/ + cache: true + cache_repo: git.asp.now/platform/claude-code-runner/cache diff --git a/.woodpecker/deploy.yaml b/.woodpecker/deploy.yaml new file mode 100644 index 0000000..a8ce090 --- /dev/null +++ b/.woodpecker/deploy.yaml @@ -0,0 +1,16 @@ +depends_on: + - build + +when: + - event: [push, tag] + branch: main + +steps: + - name: deploy + image: bitnami/kubectl:latest + commands: + - kubectl -n agent-mgr set image deployment/agent-mgr agent-mgr=git.asp.now/platform/agent-mgr:${CI_COMMIT_SHA:0:8} + - kubectl -n agent-mgr rollout status deployment/agent-mgr --timeout=120s + backend_options: + kubernetes: + serviceAccountName: woodpecker-deployer diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e1f9fc3 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build build-ui build-go run clean docker docker-runner + +# Build UI, then Go binary +build: build-ui build-go + +build-ui: + cd ui && npm ci --ignore-scripts && npm run build + +build-go: + go build -o bin/agent-mgr ./cmd/agent-mgr + +run: build + ./bin/agent-mgr + +clean: + rm -rf bin/ ui/dist/index.js ui/node_modules + +# Docker images +docker: + docker build -f docker/Dockerfile -t agent-mgr . + +docker-runner: + docker build -f docker/Dockerfile.claude-runner -t claude-code-runner docker/ + +# Development: run with local kubeconfig +dev: build-ui + KUBECONFIG=~/.kube/n2d-1-config \ + DATABASE_URL=postgres://localhost:5432/agent_mgr_db?sslmode=disable \ + GITEA_URL=https://git.asp.now \ + GITEA_TOKEN= \ + GITEA_BOT_USER=agent-mgr-bot \ + go run ./cmd/agent-mgr diff --git a/cmd/agent-mgr/main.go b/cmd/agent-mgr/main.go new file mode 100644 index 0000000..ff839b8 --- /dev/null +++ b/cmd/agent-mgr/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "io/fs" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/agentsphere/agent-mgr/internal/api" + "github.com/agentsphere/agent-mgr/internal/app" + "github.com/agentsphere/agent-mgr/internal/events" + "github.com/agentsphere/agent-mgr/internal/gitea" + "github.com/agentsphere/agent-mgr/internal/provider" + "github.com/agentsphere/agent-mgr/internal/provider/claudecode" + "github.com/agentsphere/agent-mgr/internal/store" + "github.com/agentsphere/agent-mgr/ui" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + // Config from env + listenAddr := envOr("LISTEN_ADDR", ":8080") + databaseURL := envOr("DATABASE_URL", "postgres://localhost:5432/agent_mgr_db?sslmode=disable") + giteaURL := envOr("GITEA_URL", "https://git.asp.now") + giteaToken := envOr("GITEA_TOKEN", "") + giteaBotUser := envOr("GITEA_BOT_USER", "agent-mgr-bot") + valkeyAddr := envOr("VALKEY_ADDR", "valkey.valkey.svc.cluster.local:6379") + valkeyPassword := envOr("VALKEY_PASSWORD", "") + + // Postgres + db, err := store.New(ctx, databaseURL) + if err != nil { + log.Error("failed to connect to postgres", "err", err) + os.Exit(1) + } + defer db.Close() + + if err := db.Migrate(ctx); err != nil { + log.Error("failed to run migrations", "err", err) + os.Exit(1) + } + + // Kubernetes client + restConfig, err := kubeConfig() + if err != nil { + log.Error("failed to get kube config", "err", err) + os.Exit(1) + } + k8s, err := kubernetes.NewForConfig(restConfig) + if err != nil { + log.Error("failed to create kube client", "err", err) + os.Exit(1) + } + + // Providers + registry := provider.NewRegistry() + registry.Register(claudecode.New(k8s, restConfig)) + + // Gitea + giteaClient := gitea.New(giteaURL, giteaToken, giteaBotUser) + + // Events + bus := events.NewBus(valkeyAddr, valkeyPassword) + defer bus.Close() + + // App service + appSvc := app.NewService(db, giteaClient, registry, log) + + // Static UI files + var staticFS fs.FS + uiFS := ui.StaticFS() + if uiFS != nil { + staticFS = uiFS + } + + // HTTP server + srv := api.NewServer(db, appSvc, registry, bus, log) + httpSrv := &http.Server{ + Addr: listenAddr, + Handler: srv.Handler(staticFS), + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + go func() { + log.Info("starting server", "addr", listenAddr) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + log.Info("shutting down...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + httpSrv.Shutdown(shutdownCtx) +} + +func kubeConfig() (*rest.Config, error) { + // In-cluster first + cfg, err := rest.InClusterConfig() + if err == nil { + return cfg, nil + } + // Fallback to kubeconfig + kubeconfigPath := envOr("KUBECONFIG", os.Getenv("HOME")+"/.kube/config") + return clientcmd.BuildConfigFromFlags("", kubeconfigPath) +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f560531 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,23 @@ +# Stage 1: Build UI +FROM node:22-slim AS ui-builder +WORKDIR /ui +COPY ui/package.json ui/package-lock.json* ./ +RUN npm ci --ignore-scripts +COPY ui/ . +RUN npm run build + +# Stage 2: Build Go binary +FROM golang:1.23-alpine AS go-builder +RUN apk add --no-cache git +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=ui-builder /ui/dist ./ui/dist +RUN CGO_ENABLED=0 go build -o /agent-mgr ./cmd/agent-mgr + +# Stage 3: Runtime +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=go-builder /agent-mgr /agent-mgr +EXPOSE 8080 +ENTRYPOINT ["/agent-mgr"] diff --git a/docker/Dockerfile.claude-runner b/docker/Dockerfile.claude-runner new file mode 100644 index 0000000..211ba03 --- /dev/null +++ b/docker/Dockerfile.claude-runner @@ -0,0 +1,17 @@ +FROM node:22-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @anthropic-ai/claude-code + +RUN useradd -m -s /bin/bash agent +USER agent +WORKDIR /workspace + +COPY --chown=agent:agent entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..0bfe3a2 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail +cd /workspace + +# Run claude interactively, streaming JSON output +claude --output-format stream-json "$@" +EXIT_CODE=$? + +# After claude exits, push whatever it did +if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "agent session ${SESSION_ID:-unknown}" + git push origin "agent/${SESSION_ID:-unknown}" +fi + +exit $EXIT_CODE diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eacd3ad --- /dev/null +++ b/go.mod @@ -0,0 +1,62 @@ +module github.com/agentsphere/agent-mgr + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/redis/go-redis/v9 v9.18.0 + k8s.io/api v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 + nhooyr.io/websocket v1.8.17 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..752b977 --- /dev/null +++ b/go.sum @@ -0,0 +1,166 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +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= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +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/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +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/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +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= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/api/apps.go b/internal/api/apps.go new file mode 100644 index 0000000..0932a6f --- /dev/null +++ b/internal/api/apps.go @@ -0,0 +1,105 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/agentsphere/agent-mgr/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type createAppRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Provider string `json:"provider,omitempty"` + Config json.RawMessage `json:"config,omitempty"` +} + +func (s *Server) createApp(w http.ResponseWriter, r *http.Request) { + var req createAppRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + if req.Name == "" && req.Description == "" { + http.Error(w, `{"error":"name or description required"}`, http.StatusBadRequest) + return + } + if req.Name == "" { + // Auto-generate name from first few words of description + name := req.Description + if len(name) > 40 { + name = name[:40] + } + req.Name = name + } + + app, sess, err := s.app.CreateApp(r.Context(), req.Name, req.Description, req.Provider, req.Config) + if err != nil { + s.log.Error("create app failed", "err", err) + http.Error(w, `{"error":"failed to create app"}`, http.StatusInternalServerError) + return + } + + resp := map[string]any{"app": app} + if sess != nil { + resp["session"] = sess + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) listApps(w http.ResponseWriter, r *http.Request) { + apps, err := s.store.ListApps(r.Context()) + if err != nil { + s.log.Error("list apps failed", "err", err) + http.Error(w, `{"error":"failed to list apps"}`, http.StatusInternalServerError) + return + } + if apps == nil { + apps = []store.App{} + } + json.NewEncoder(w).Encode(map[string]any{"apps": apps}) +} + +func (s *Server) getApp(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "appID")) + if err != nil { + http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest) + return + } + + app, err := s.store.GetApp(r.Context(), id) + if err != nil { + http.Error(w, `{"error":"failed to get app"}`, http.StatusInternalServerError) + return + } + if app == nil { + http.Error(w, `{"error":"app not found"}`, http.StatusNotFound) + return + } + + sessions, _ := s.store.ListSessionsByApp(r.Context(), id) + if sessions == nil { + sessions = []store.Session{} + } + + json.NewEncoder(w).Encode(map[string]any{"app": app, "sessions": sessions}) +} + +func (s *Server) deleteApp(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "appID")) + if err != nil { + http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest) + return + } + + if err := s.store.DeleteApp(r.Context(), id); err != nil { + http.Error(w, `{"error":"failed to delete app"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..4bec6ad --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,77 @@ +package api + +import ( + "io/fs" + "log/slog" + "net/http" + + "github.com/agentsphere/agent-mgr/internal/app" + "github.com/agentsphere/agent-mgr/internal/events" + "github.com/agentsphere/agent-mgr/internal/provider" + "github.com/agentsphere/agent-mgr/internal/store" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +type Server struct { + store *store.Store + app *app.Service + registry *provider.Registry + events *events.Bus + log *slog.Logger +} + +func NewServer(st *store.Store, appSvc *app.Service, reg *provider.Registry, bus *events.Bus, log *slog.Logger) *Server { + return &Server{store: st, app: appSvc, registry: reg, events: bus, log: log} +} + +func (s *Server) Handler(staticFS fs.FS) http.Handler { + r := chi.NewRouter() + + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(middleware.Compress(5)) + + // API routes + r.Route("/api/v1", func(r chi.Router) { + r.Use(middleware.SetHeader("Content-Type", "application/json")) + + r.Post("/apps", s.createApp) + r.Get("/apps", s.listApps) + r.Get("/apps/{appID}", s.getApp) + r.Delete("/apps/{appID}", s.deleteApp) + + r.Post("/apps/{appID}/sessions", s.createSession) + r.Get("/apps/{appID}/sessions", s.listSessions) + + r.Get("/sessions/{sessionID}", s.getSession) + r.Post("/sessions/{sessionID}/stop", s.stopSession) + r.Post("/sessions/{sessionID}/message", s.sendMessage) + r.Get("/sessions/{sessionID}/ws", s.streamSession) + + r.Get("/providers", s.listProviders) + + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"ok"}`)) + }) + }) + + // Serve SPA for all other routes + if staticFS != nil { + fileServer := http.FileServer(http.FS(staticFS)) + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + // Try to serve the file; if not found, serve index.html (SPA routing) + path := r.URL.Path + f, err := staticFS.Open(path[1:]) // strip leading / + if err != nil { + // Serve index.html for SPA client-side routing + r.URL.Path = "/" + } else { + f.Close() + } + fileServer.ServeHTTP(w, r) + }) + } + + return r +} diff --git a/internal/api/sessions.go b/internal/api/sessions.go new file mode 100644 index 0000000..78005aa --- /dev/null +++ b/internal/api/sessions.go @@ -0,0 +1,136 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/agentsphere/agent-mgr/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type createSessionRequest struct { + Prompt string `json:"prompt"` + Provider string `json:"provider,omitempty"` + Config json.RawMessage `json:"config,omitempty"` +} + +type sendMessageRequest struct { + Message string `json:"message"` +} + +func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { + appID, err := uuid.Parse(chi.URLParam(r, "appID")) + if err != nil { + http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest) + return + } + + var req createSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + if req.Prompt == "" { + http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest) + return + } + + app, err := s.store.GetApp(r.Context(), appID) + if err != nil || app == nil { + http.Error(w, `{"error":"app not found"}`, http.StatusNotFound) + return + } + + sess, err := s.app.StartSession(r.Context(), app, req.Prompt, req.Provider, req.Config) + if err != nil { + s.log.Error("create session failed", "err", err) + http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"session": sess}) +} + +func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) { + appID, err := uuid.Parse(chi.URLParam(r, "appID")) + if err != nil { + http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest) + return + } + + sessions, err := s.store.ListSessionsByApp(r.Context(), appID) + if err != nil { + http.Error(w, `{"error":"failed to list sessions"}`, http.StatusInternalServerError) + return + } + if sessions == nil { + sessions = []store.Session{} + } + + json.NewEncoder(w).Encode(map[string]any{"sessions": sessions}) +} + +func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "sessionID")) + if err != nil { + http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest) + return + } + + sess, err := s.store.GetSession(r.Context(), id) + if err != nil || sess == nil { + http.Error(w, `{"error":"session not found"}`, http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(map[string]any{"session": sess}) +} + +func (s *Server) stopSession(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "sessionID")) + if err != nil { + http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest) + return + } + + if err := s.app.StopSession(r.Context(), id); err != nil { + s.log.Error("stop session failed", "err", err) + http.Error(w, `{"error":"failed to stop session"}`, http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]any{"status": "stopped"}) +} + +func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "sessionID")) + if err != nil { + http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest) + return + } + + var req sendMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + if req.Message == "" { + http.Error(w, `{"error":"message required"}`, http.StatusBadRequest) + return + } + + if err := s.app.SendMessage(r.Context(), id, req.Message); err != nil { + s.log.Error("send message failed", "err", err) + http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]any{"status": "sent"}) +} + +func (s *Server) listProviders(w http.ResponseWriter, r *http.Request) { + infos := s.registry.List() + json.NewEncoder(w).Encode(map[string]any{"providers": infos}) +} diff --git a/internal/api/ws.go b/internal/api/ws.go new file mode 100644 index 0000000..63e1279 --- /dev/null +++ b/internal/api/ws.go @@ -0,0 +1,74 @@ +package api + +import ( + "bufio" + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "nhooyr.io/websocket" + + "github.com/agentsphere/agent-mgr/internal/provider" + "github.com/agentsphere/agent-mgr/internal/provider/claudecode" +) + +func (s *Server) streamSession(w http.ResponseWriter, r *http.Request) { + sessID, err := uuid.Parse(chi.URLParam(r, "sessionID")) + if err != nil { + http.Error(w, "invalid session id", http.StatusBadRequest) + return + } + + sess, err := s.store.GetSession(r.Context(), sessID) + if err != nil || sess == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + + p, err := s.registry.Get(sess.Provider) + if err != nil { + http.Error(w, "provider not found", http.StatusInternalServerError) + return + } + + handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName} + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // Traefik handles TLS/origin + }) + if err != nil { + s.log.Error("websocket accept failed", "err", err) + return + } + defer conn.CloseNow() + + ctx := conn.CloseRead(r.Context()) + + stream, err := p.StreamOutput(ctx, handle) + if err != nil { + s.log.Error("stream output failed", "err", err, "pod", sess.PodName) + conn.Close(websocket.StatusInternalError, "failed to stream logs") + return + } + defer stream.Close() + + tracker := claudecode.NewProgressTracker() + scanner := bufio.NewScanner(stream) + scanner.Buffer(make([]byte, 64*1024), 64*1024) + + for scanner.Scan() { + line := scanner.Text() + tracker.ProcessLine(line) + + if err := conn.Write(ctx, websocket.MessageText, []byte(line)); err != nil { + break + } + } + + if err := scanner.Err(); err != nil && err != io.EOF { + s.log.Warn("stream scanner error", "err", err) + } + + conn.Close(websocket.StatusNormalClosure, "stream ended") +} diff --git a/internal/app/service.go b/internal/app/service.go new file mode 100644 index 0000000..11abd52 --- /dev/null +++ b/internal/app/service.go @@ -0,0 +1,170 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/agentsphere/agent-mgr/internal/gitea" + "github.com/agentsphere/agent-mgr/internal/provider" + "github.com/agentsphere/agent-mgr/internal/store" + "github.com/google/uuid" +) + +type Service struct { + store *store.Store + gitea *gitea.Client + registry *provider.Registry + log *slog.Logger +} + +func NewService(s *store.Store, g *gitea.Client, r *provider.Registry, log *slog.Logger) *Service { + return &Service{store: s, gitea: g, registry: r, log: log} +} + +// CreateApp creates an app record, a Gitea repo, and starts the first build session. +func (svc *Service) CreateApp(ctx context.Context, name, description, providerName string, providerConfig json.RawMessage) (*store.App, *store.Session, error) { + repoName := slugify(name) + + app, err := svc.store.CreateApp(ctx, name, description) + if err != nil { + return nil, nil, fmt.Errorf("create app: %w", err) + } + + repo, err := svc.gitea.CreateRepo(ctx, repoName, description) + if err != nil { + _ = svc.store.DeleteApp(ctx, app.ID) + return nil, nil, fmt.Errorf("create gitea repo: %w", err) + } + + if err := svc.store.UpdateAppRepo(ctx, app.ID, repo.FullName[:strings.Index(repo.FullName, "/")], repo.Name); err != nil { + return nil, nil, fmt.Errorf("update app repo: %w", err) + } + app.RepoOwner = repo.FullName[:strings.Index(repo.FullName, "/")] + app.RepoName = repo.Name + + sess, err := svc.StartSession(ctx, app, description, providerName, providerConfig) + if err != nil { + return app, nil, fmt.Errorf("start initial session: %w", err) + } + + return app, sess, nil +} + +// StartSession creates a new session on an existing app and spawns the agent pod. +func (svc *Service) StartSession(ctx context.Context, app *store.App, prompt, providerName string, providerConfig json.RawMessage) (*store.Session, error) { + if providerName == "" { + providerName = "claude-code" + } + + p, err := svc.registry.Get(providerName) + if err != nil { + return nil, err + } + + sessID := uuid.New() + branch := fmt.Sprintf("agent/%s", sessID.String()[:8]) + + configBytes := providerConfig + if configBytes == nil { + configBytes = json.RawMessage(`{}`) + } + + sess, err := svc.store.CreateSession(ctx, app.ID, providerName, prompt, branch, configBytes) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + // Override the auto-generated ID with our pre-generated one for branch consistency + // (the DB generates its own UUID, but the branch name uses sessID) + // We'll use the DB-generated ID going forward + branch = fmt.Sprintf("agent/%s", sess.ID.String()[:8]) + _ = svc.store.UpdateSessionStatus(ctx, sess.ID, "pending") + sess.Branch = branch + + cloneURL := svc.gitea.AuthCloneURL(app.RepoOwner, app.RepoName) + + systemPrompt := fmt.Sprintf("You are building an app called %q. The user's idea: %s\nCreate a complete, working application. When done, ensure all files are committed.", app.Name, app.Description) + fullPrompt := prompt + if prompt == app.Description { + fullPrompt = systemPrompt + } + + handle, err := p.CreateSession(ctx, provider.SessionConfig{ + SessionID: sess.ID, + AppName: app.Name, + RepoClone: cloneURL, + Branch: branch, + Prompt: fullPrompt, + Provider: configBytes, + }) + if err != nil { + _ = svc.store.UpdateSessionStatus(ctx, sess.ID, "failed") + return nil, fmt.Errorf("create agent session: %w", err) + } + + _ = svc.store.UpdateSessionPod(ctx, sess.ID, handle.PodName) + _ = svc.store.UpdateSessionStatus(ctx, sess.ID, "running") + _ = svc.store.UpdateAppStatus(ctx, app.ID, "building") + + sess.PodName = handle.PodName + sess.Status = "running" + + svc.log.Info("session started", "app", app.Name, "session", sess.ID, "pod", handle.PodName) + return sess, nil +} + +func (svc *Service) StopSession(ctx context.Context, sessID uuid.UUID) error { + sess, err := svc.store.GetSession(ctx, sessID) + if err != nil || sess == nil { + return fmt.Errorf("session not found: %w", err) + } + + p, err := svc.registry.Get(sess.Provider) + if err != nil { + return err + } + + handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName} + if err := p.StopSession(ctx, handle); err != nil { + svc.log.Warn("failed to stop pod", "pod", sess.PodName, "err", err) + } + + _ = svc.store.UpdateSessionStatus(ctx, sessID, "stopped") + return nil +} + +func (svc *Service) SendMessage(ctx context.Context, sessID uuid.UUID, msg string) error { + sess, err := svc.store.GetSession(ctx, sessID) + if err != nil || sess == nil { + return fmt.Errorf("session not found") + } + if sess.Status != "running" { + return fmt.Errorf("session not running (status: %s)", sess.Status) + } + + p, err := svc.registry.Get(sess.Provider) + if err != nil { + return err + } + + handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName} + return p.SendMessage(ctx, handle, msg) +} + +var nonAlphaNum = regexp.MustCompile(`[^a-z0-9-]+`) + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = nonAlphaNum.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if len(s) > 50 { + s = s[:50] + } + if s == "" { + s = "app" + } + return s +} diff --git a/internal/events/valkey.go b/internal/events/valkey.go new file mode 100644 index 0000000..f739313 --- /dev/null +++ b/internal/events/valkey.go @@ -0,0 +1,71 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/redis/go-redis/v9" +) + +const channel = "agent-mgr:events" + +type Bus struct { + rdb *redis.Client +} + +type Event struct { + Type string `json:"type"` // "session.started", "session.completed", "session.failed", "session.stopped", "app.status" + SessionID string `json:"session_id,omitempty"` + AppID string `json:"app_id,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` +} + +func NewBus(addr, password string) *Bus { + return &Bus{ + rdb: redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + }), + } +} + +func (b *Bus) Publish(ctx context.Context, evt Event) error { + data, err := json.Marshal(evt) + if err != nil { + return fmt.Errorf("marshal event: %w", err) + } + return b.rdb.Publish(ctx, channel, data).Err() +} + +func (b *Bus) Subscribe(ctx context.Context) (<-chan Event, error) { + sub := b.rdb.Subscribe(ctx, channel) + ch := make(chan Event, 64) + + go func() { + defer close(ch) + defer sub.Close() + for { + msg, err := sub.ReceiveMessage(ctx) + if err != nil { + return + } + var evt Event + if err := json.Unmarshal([]byte(msg.Payload), &evt); err != nil { + continue + } + select { + case ch <- evt: + case <-ctx.Done(): + return + } + } + }() + + return ch, nil +} + +func (b *Bus) Close() error { + return b.rdb.Close() +} diff --git a/internal/gitea/client.go b/internal/gitea/client.go new file mode 100644 index 0000000..b69beb6 --- /dev/null +++ b/internal/gitea/client.go @@ -0,0 +1,142 @@ +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Client struct { + baseURL string + token string + botUser string + http *http.Client +} + +func New(baseURL, token, botUser string) *Client { + return &Client{ + baseURL: baseURL, + token: token, + botUser: botUser, + http: &http.Client{}, + } +} + +type Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` + Empty bool `json:"empty"` +} + +type Branch struct { + Name string `json:"name"` +} + +func (c *Client) CreateRepo(ctx context.Context, name, description string) (*Repo, error) { + body, _ := json.Marshal(map[string]any{ + "name": name, + "description": description, + "auto_init": true, + "default_branch": "main", + "private": false, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/user/repos", bytes.NewReader(body)) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("create repo: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("create repo: status %d: %s", resp.StatusCode, b) + } + + var repo Repo + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return nil, fmt.Errorf("decode repo: %w", err) + } + return &repo, nil +} + +func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/user/repos?limit=50", nil) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("list repos: status %d: %s", resp.StatusCode, b) + } + + var repos []Repo + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + return nil, fmt.Errorf("decode repos: %w", err) + } + return repos, nil +} + +func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]Branch, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches", c.baseURL, owner, repo) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("list branches: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("list branches: status %d: %s", resp.StatusCode, b) + } + + var branches []Branch + if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil { + return nil, fmt.Errorf("decode branches: %w", err) + } + return branches, nil +} + +// AuthCloneURL returns a clone URL with embedded bot credentials. +func (c *Client) AuthCloneURL(owner, repo string) string { + // Strip protocol from base URL + base := c.baseURL + if len(base) > 8 && base[:8] == "https://" { + base = base[8:] + } else if len(base) > 7 && base[:7] == "http://" { + base = base[7:] + } + return fmt.Sprintf("https://%s:%s@%s/%s/%s.git", c.botUser, c.token, base, owner, repo) +} + +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") +} diff --git a/internal/provider/claudecode/adapter.go b/internal/provider/claudecode/adapter.go new file mode 100644 index 0000000..3e46004 --- /dev/null +++ b/internal/provider/claudecode/adapter.go @@ -0,0 +1,127 @@ +package claudecode + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + + "github.com/agentsphere/agent-mgr/internal/provider" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" +) + +type Config struct { + Model string `json:"model,omitempty"` + MaxTurns int `json:"max_turns,omitempty"` +} + +type Provider struct { + client kubernetes.Interface + restConfig *rest.Config +} + +func New(client kubernetes.Interface, restConfig *rest.Config) *Provider { + return &Provider{client: client, restConfig: restConfig} +} + +func (p *Provider) Info() provider.Info { + return provider.Info{ + Name: "claude-code", + DisplayName: "Claude Code", + Description: "Anthropic Claude Code CLI agent — builds full applications from natural language", + Capabilities: []provider.Capability{ + {Name: "create-app", Description: "Create new applications from scratch"}, + {Name: "edit-code", Description: "Modify existing codebases"}, + {Name: "interactive", Description: "Supports follow-up messages during a session"}, + }, + ConfigSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "model": {"type": "string", "description": "Claude model to use", "default": ""}, + "max_turns": {"type": "integer", "description": "Maximum agentic turns", "default": 0} + } + }`), + } +} + +func (p *Provider) CreateSession(ctx context.Context, cfg provider.SessionConfig) (*provider.SessionHandle, error) { + var opts *Config + if len(cfg.Provider) > 0 { + opts = &Config{} + if err := json.Unmarshal(cfg.Provider, opts); err != nil { + return nil, fmt.Errorf("parse claude-code config: %w", err) + } + } + + pod := buildPod(cfg, opts) + created, err := p.client.CoreV1().Pods(Namespace).Create(ctx, pod, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create pod: %w", err) + } + + return &provider.SessionHandle{ + SessionID: cfg.SessionID, + PodName: created.Name, + }, nil +} + +func (p *Provider) StopSession(ctx context.Context, handle *provider.SessionHandle) error { + return p.client.CoreV1().Pods(Namespace).Delete(ctx, handle.PodName, metav1.DeleteOptions{}) +} + +func (p *Provider) SendMessage(ctx context.Context, handle *provider.SessionHandle, msg string) error { + req := p.client.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(handle.PodName). + Namespace(Namespace). + SubResource("attach"). + VersionedParams(&corev1.PodAttachOptions{ + Container: "claude", + Stdin: true, + Stdout: false, + Stderr: false, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(p.restConfig, "POST", req.URL()) + if err != nil { + return fmt.Errorf("create attach executor: %w", err) + } + + return exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: bytes.NewReader([]byte(msg + "\n")), + }) +} + +func (p *Provider) StreamOutput(ctx context.Context, handle *provider.SessionHandle) (io.ReadCloser, error) { + req := p.client.CoreV1().Pods(Namespace).GetLogs(handle.PodName, &corev1.PodLogOptions{ + Container: "claude", + Follow: true, + }) + return req.Stream(ctx) +} + +func (p *Provider) GetStatus(ctx context.Context, handle *provider.SessionHandle) (provider.Status, error) { + pod, err := p.client.CoreV1().Pods(Namespace).Get(ctx, handle.PodName, metav1.GetOptions{}) + if err != nil { + return provider.StatusFailed, fmt.Errorf("get pod: %w", err) + } + + switch pod.Status.Phase { + case corev1.PodPending: + return provider.StatusPending, nil + case corev1.PodRunning: + return provider.StatusRunning, nil + case corev1.PodSucceeded: + return provider.StatusCompleted, nil + case corev1.PodFailed: + return provider.StatusFailed, nil + default: + return provider.StatusFailed, nil + } +} diff --git a/internal/provider/claudecode/pod.go b/internal/provider/claudecode/pod.go new file mode 100644 index 0000000..80f5587 --- /dev/null +++ b/internal/provider/claudecode/pod.go @@ -0,0 +1,122 @@ +package claudecode + +import ( + "fmt" + + "github.com/agentsphere/agent-mgr/internal/provider" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + Namespace = "agent-mgr" + RunnerImage = "git.asp.now/platform/claude-code-runner:latest" + GitImage = "alpine/git:latest" +) + +func buildPod(cfg provider.SessionConfig, opts *Config) *corev1.Pod { + podName := fmt.Sprintf("agent-%s", cfg.SessionID.String()[:8]) + branch := cfg.Branch + + cloneScript := fmt.Sprintf(` +set -eu +git clone %s /workspace +cd /workspace +git checkout -b %s +git config user.name "agent-mgr-bot" +git config user.email "bot@asp.now" +`, cfg.RepoClone, branch) + + claudeArgs := []string{"--output-format", "stream-json", "--permission-mode", "auto-accept-only"} + if opts != nil { + if opts.Model != "" { + claudeArgs = append(claudeArgs, "--model", opts.Model) + } + if opts.MaxTurns > 0 { + claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns)) + } + } + + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: Namespace, + Labels: map[string]string{ + "app": "agent-session", + "session-id": cfg.SessionID.String(), + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: "agent-runner", + InitContainers: []corev1.Container{ + { + Name: "git-clone", + Image: GitImage, + Command: []string{"sh", "-c", cloneScript}, + VolumeMounts: []corev1.VolumeMount{ + {Name: "workspace", MountPath: "/workspace"}, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "claude", + Image: RunnerImage, + Args: claudeArgs, + Stdin: true, + TTY: false, + WorkingDir: "/workspace", + Env: []corev1.EnvVar{ + { + Name: "ANTHROPIC_API_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "agent-mgr-secrets"}, + Key: "anthropic-api-key", + }, + }, + }, + {Name: "SESSION_ID", Value: cfg.SessionID.String()}, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "workspace", MountPath: "/workspace"}, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("512Mi"), + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "workspace", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + SizeLimit: resourcePtr(resource.MustParse("1Gi")), + }, + }, + }, + }, + }, + } +} + +func resourcePtr(q resource.Quantity) *resource.Quantity { return &q } diff --git a/internal/provider/claudecode/progress.go b/internal/provider/claudecode/progress.go new file mode 100644 index 0000000..63175e8 --- /dev/null +++ b/internal/provider/claudecode/progress.go @@ -0,0 +1,75 @@ +package claudecode + +import "strings" + +type Milestone struct { + Label string `json:"label"` + Status string `json:"status"` // "done", "active", "pending" +} + +type ProgressTracker struct { + filesSeen map[string]bool + milestones []Milestone +} + +func NewProgressTracker() *ProgressTracker { + return &ProgressTracker{ + filesSeen: make(map[string]bool), + milestones: []Milestone{ + {Label: "Setting up project", Status: "active"}, + {Label: "Creating data models", Status: "pending"}, + {Label: "Building UI components", Status: "pending"}, + {Label: "Adding styling and polish", Status: "pending"}, + {Label: "Final checks", Status: "pending"}, + }, + } +} + +func (pt *ProgressTracker) Milestones() []Milestone { + cp := make([]Milestone, len(pt.milestones)) + copy(cp, pt.milestones) + return cp +} + +func (pt *ProgressTracker) ProcessLine(line string) { + lower := strings.ToLower(line) + + if strings.Contains(lower, "\"tool\":\"write\"") || strings.Contains(lower, "\"tool\":\"create\"") { + pt.advanceTo(0) + } + + if containsAny(lower, "model", "schema", "database", "migration", "struct", "type ") { + pt.advanceTo(1) + } + + if containsAny(lower, "component", "page", "route", "template", "html", "tsx", "jsx", "vue") { + pt.advanceTo(2) + } + + if containsAny(lower, "css", "style", "tailwind", "theme", "color", "font", "layout") { + pt.advanceTo(3) + } + + if containsAny(lower, "test", "readme", "done", "complete", "finish", "final") { + pt.advanceTo(4) + } +} + +func (pt *ProgressTracker) advanceTo(idx int) { + for i := range pt.milestones { + if i < idx { + pt.milestones[i].Status = "done" + } else if i == idx { + pt.milestones[i].Status = "active" + } + } +} + +func containsAny(s string, substrs ...string) bool { + for _, sub := range substrs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..1804a36 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,55 @@ +package provider + +import ( + "context" + "encoding/json" + "io" + + "github.com/google/uuid" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusCompleted Status = "completed" + StatusFailed Status = "failed" + StatusStopped Status = "stopped" +) + +type SessionConfig struct { + SessionID uuid.UUID `json:"session_id"` + AppName string `json:"app_name"` + RepoClone string `json:"repo_clone_url"` + Branch string `json:"branch"` + Prompt string `json:"prompt"` + Provider json.RawMessage `json:"provider_config,omitempty"` +} + +type SessionHandle struct { + SessionID uuid.UUID + PodName string +} + +type Capability struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type Info struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Capabilities []Capability `json:"capabilities"` + ConfigSchema json.RawMessage `json:"config_schema,omitempty"` +} + +type AgentProvider interface { + Info() Info + CreateSession(ctx context.Context, cfg SessionConfig) (*SessionHandle, error) + StopSession(ctx context.Context, handle *SessionHandle) error + SendMessage(ctx context.Context, handle *SessionHandle, msg string) error + StreamOutput(ctx context.Context, handle *SessionHandle) (io.ReadCloser, error) + GetStatus(ctx context.Context, handle *SessionHandle) (Status, error) +} diff --git a/internal/provider/registry.go b/internal/provider/registry.go new file mode 100644 index 0000000..0dce76c --- /dev/null +++ b/internal/provider/registry.go @@ -0,0 +1,31 @@ +package provider + +import "fmt" + +type Registry struct { + providers map[string]AgentProvider +} + +func NewRegistry() *Registry { + return &Registry{providers: make(map[string]AgentProvider)} +} + +func (r *Registry) Register(p AgentProvider) { + r.providers[p.Info().Name] = p +} + +func (r *Registry) Get(name string) (AgentProvider, error) { + p, ok := r.providers[name] + if !ok { + return nil, fmt.Errorf("provider %q not found", name) + } + return p, nil +} + +func (r *Registry) List() []Info { + infos := make([]Info, 0, len(r.providers)) + for _, p := range r.providers { + infos = append(infos, p.Info()) + } + return infos +} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql new file mode 100644 index 0000000..93f877a --- /dev/null +++ b/internal/store/migrations/001_init.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS apps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', + repo_owner TEXT NOT NULL DEFAULT '', + repo_name TEXT NOT NULL DEFAULT '', + preview_url TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'claude-code', + status TEXT NOT NULL DEFAULT 'pending', + prompt TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + pod_name TEXT NOT NULL DEFAULT '', + config JSONB NOT NULL DEFAULT '{}', + cost_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_sessions_app_id ON sessions(app_id); +CREATE INDEX idx_sessions_status ON sessions(status); +CREATE INDEX idx_apps_status ON apps(status); diff --git a/internal/store/postgres.go b/internal/store/postgres.go new file mode 100644 index 0000000..1014b57 --- /dev/null +++ b/internal/store/postgres.go @@ -0,0 +1,202 @@ +package store + +import ( + "context" + "embed" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type Store struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, dsn string) (*Store, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("connect to postgres: %w", err) + } + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping postgres: %w", err) + } + return &Store{pool: pool}, nil +} + +func (s *Store) Migrate(ctx context.Context) error { + sql, err := migrationsFS.ReadFile("migrations/001_init.sql") + if err != nil { + return fmt.Errorf("read migration: %w", err) + } + _, err = s.pool.Exec(ctx, string(sql)) + if err != nil { + return fmt.Errorf("run migration: %w", err) + } + return nil +} + +func (s *Store) Close() { + s.pool.Close() +} + +// App + +type App struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + RepoOwner string `json:"repo_owner,omitempty"` + RepoName string `json:"repo_name,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *Store) CreateApp(ctx context.Context, name, description string) (*App, error) { + app := &App{} + err := s.pool.QueryRow(ctx, + `INSERT INTO apps (name, description) VALUES ($1, $2) + RETURNING id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at`, + name, description, + ).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("create app: %w", err) + } + return app, nil +} + +func (s *Store) GetApp(ctx context.Context, id uuid.UUID) (*App, error) { + app := &App{} + err := s.pool.QueryRow(ctx, + `SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at + FROM apps WHERE id = $1`, id, + ).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get app: %w", err) + } + return app, nil +} + +func (s *Store) ListApps(ctx context.Context) ([]App, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at + FROM apps ORDER BY updated_at DESC`) + if err != nil { + return nil, fmt.Errorf("list apps: %w", err) + } + defer rows.Close() + + var apps []App + for rows.Next() { + var a App + if err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Status, &a.RepoOwner, &a.RepoName, &a.PreviewURL, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan app: %w", err) + } + apps = append(apps, a) + } + return apps, nil +} + +func (s *Store) UpdateAppStatus(ctx context.Context, id uuid.UUID, status string) error { + _, err := s.pool.Exec(ctx, + `UPDATE apps SET status = $1, updated_at = now() WHERE id = $2`, status, id) + return err +} + +func (s *Store) UpdateAppRepo(ctx context.Context, id uuid.UUID, owner, name string) error { + _, err := s.pool.Exec(ctx, + `UPDATE apps SET repo_owner = $1, repo_name = $2, updated_at = now() WHERE id = $3`, owner, name, id) + return err +} + +func (s *Store) DeleteApp(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM apps WHERE id = $1`, id) + return err +} + +// Session + +type Session struct { + ID uuid.UUID `json:"id"` + AppID uuid.UUID `json:"app_id"` + Provider string `json:"provider"` + Status string `json:"status"` + Prompt string `json:"prompt"` + Branch string `json:"branch"` + PodName string `json:"pod_name,omitempty"` + Config []byte `json:"config,omitempty"` + CostJSON []byte `json:"cost,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *Store) CreateSession(ctx context.Context, appID uuid.UUID, provider, prompt, branch string, config []byte) (*Session, error) { + sess := &Session{} + err := s.pool.QueryRow(ctx, + `INSERT INTO sessions (app_id, provider, prompt, branch, config) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at`, + appID, provider, prompt, branch, config, + ).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + return sess, nil +} + +func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) { + sess := &Session{} + err := s.pool.QueryRow(ctx, + `SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at + FROM sessions WHERE id = $1`, id, + ).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get session: %w", err) + } + return sess, nil +} + +func (s *Store) ListSessionsByApp(ctx context.Context, appID uuid.UUID) ([]Session, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at + FROM sessions WHERE app_id = $1 ORDER BY created_at DESC`, appID) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + defer rows.Close() + + var sessions []Session + for rows.Next() { + var sess Session + if err := rows.Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan session: %w", err) + } + sessions = append(sessions, sess) + } + return sessions, nil +} + +func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status string) error { + _, err := s.pool.Exec(ctx, + `UPDATE sessions SET status = $1, updated_at = now() WHERE id = $2`, status, id) + return err +} + +func (s *Store) UpdateSessionPod(ctx context.Context, id uuid.UUID, podName string) error { + _, err := s.pool.Exec(ctx, + `UPDATE sessions SET pod_name = $1, updated_at = now() WHERE id = $2`, podName, id) + return err +} diff --git a/ui/dist/index.html b/ui/dist/index.html new file mode 100644 index 0000000..fbcda10 --- /dev/null +++ b/ui/dist/index.html @@ -0,0 +1,24 @@ + + + + + + asp.now + + + +
+ + + diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 0000000..d5c3427 --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,17 @@ +package ui + +import ( + "embed" + "io/fs" +) + +//go:embed dist/* +var distFS embed.FS + +func StaticFS() fs.FS { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + return nil + } + return sub +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..ac8e467 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,16 @@ +{ + "name": "agent-mgr-ui", + "private": true, + "scripts": { + "dev": "esbuild src/index.tsx --bundle --outdir=dist --servedir=dist --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"development\\\"", + "build": "esbuild src/index.tsx --bundle --outdir=dist --minify --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"production\\\"" + }, + "dependencies": { + "preact": "^10.25.0", + "preact-router": "^4.1.2" + }, + "devDependencies": { + "esbuild": "^0.24.0", + "typescript": "^5.7.0" + } +} diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 0000000..b99774f --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,97 @@ +const BASE = '/api/v1'; + +async function request(path: string, opts?: RequestInit): Promise { + const res = await fetch(BASE + path, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`${res.status}: ${body}`); + } + if (res.status === 204) return {} as T; + return res.json(); +} + +export interface App { + id: string; + name: string; + description: string; + status: string; + repo_owner: string; + repo_name: string; + preview_url: string; + created_at: string; + updated_at: string; +} + +export interface Session { + id: string; + app_id: string; + provider: string; + status: string; + prompt: string; + branch: string; + pod_name: string; + created_at: string; + updated_at: string; +} + +export interface Provider { + name: string; + display_name: string; + description: string; + capabilities: { name: string; description: string }[]; +} + +export const api = { + createApp(name: string, description: string) { + return request<{ app: App; session: Session }>('/apps', { + method: 'POST', + body: JSON.stringify({ name, description }), + }); + }, + + listApps() { + return request<{ apps: App[] }>('/apps'); + }, + + getApp(id: string) { + return request<{ app: App; sessions: Session[] }>(`/apps/${id}`); + }, + + deleteApp(id: string) { + return request<{}>(`/apps/${id}`, { method: 'DELETE' }); + }, + + createSession(appId: string, prompt: string) { + return request<{ session: Session }>(`/apps/${appId}/sessions`, { + method: 'POST', + body: JSON.stringify({ prompt }), + }); + }, + + getSession(id: string) { + return request<{ session: Session }>(`/sessions/${id}`); + }, + + stopSession(id: string) { + return request<{}>(`/sessions/${id}/stop`, { method: 'POST' }); + }, + + sendMessage(id: string, message: string) { + return request<{}>(`/sessions/${id}/message`, { + method: 'POST', + body: JSON.stringify({ message }), + }); + }, + + listProviders() { + return request<{ providers: Provider[] }>('/providers'); + }, + + sessionWsUrl(id: string): string { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}${BASE}/sessions/${id}/ws`; + }, +}; diff --git a/ui/src/components/app-card.tsx b/ui/src/components/app-card.tsx new file mode 100644 index 0000000..7759355 --- /dev/null +++ b/ui/src/components/app-card.tsx @@ -0,0 +1,77 @@ +import { App } from '../api'; +import { route } from 'preact-router'; + +interface Props { + app: App; +} + +const statusColors: Record = { + draft: 'var(--text-dim)', + building: 'var(--yellow)', + live: 'var(--green)', + stopped: 'var(--text-dim)', + failed: 'var(--red)', +}; + +const statusLabels: Record = { + draft: 'Draft', + building: 'Building...', + live: 'Live', + stopped: 'Stopped', + failed: 'Failed', +}; + +export function AppCard({ app }: Props) { + const color = statusColors[app.status] || 'var(--text-dim)'; + + return ( +
route(app.status === 'building' ? `/apps/${app.id}/live` : `/apps/${app.id}`)} + style={styles.card} + onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')} + onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--border)')} + > +
+ {app.name.slice(0, 2).toUpperCase()} +
+
{app.name}
+
+ {app.status === 'building' || app.status === 'live' ? '\u25CF ' : '\u25CB '} + {statusLabels[app.status] || app.status} +
+
+ ); +} + +const styles = { + card: { + padding: '20px', + borderRadius: '12px', + border: '1px solid var(--border)', + background: 'var(--surface)', + cursor: 'pointer', + transition: 'border-color 0.15s', + display: 'flex', + flexDirection: 'column' as const, + gap: '8px', + }, + icon: { + width: '40px', + height: '40px', + borderRadius: '8px', + background: 'var(--surface-2)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '14px', + fontWeight: '700', + color: 'var(--accent)', + }, + name: { + fontSize: '15px', + fontWeight: '600', + }, + status: { + fontSize: '13px', + }, +}; diff --git a/ui/src/components/chat.tsx b/ui/src/components/chat.tsx new file mode 100644 index 0000000..eba852e --- /dev/null +++ b/ui/src/components/chat.tsx @@ -0,0 +1,121 @@ +import { useState, useEffect, useRef } from 'preact/hooks'; + +interface Message { + from: 'agent' | 'user'; + text: string; +} + +interface Props { + messages: Message[]; + onSend: (msg: string) => void; + disabled?: boolean; +} + +export function Chat({ messages, onSend, disabled }: Props) { + const [input, setInput] = useState(''); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages.length]); + + const handleSend = (e: Event) => { + e.preventDefault(); + if (input.trim() && !disabled) { + onSend(input.trim()); + setInput(''); + } + }; + + return ( +
+
+ {messages.map((m, i) => ( +
+ {m.text} +
+ ))} +
+
+
+ setInput((e.target as HTMLInputElement).value)} + placeholder={disabled ? 'Session ended' : 'Send a follow-up...'} + disabled={disabled} + style={styles.input} + /> + +
+
+ ); +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + border: '1px solid var(--border)', + borderRadius: '12px', + background: 'var(--surface)', + overflow: 'hidden', + }, + messages: { + flex: 1, + padding: '16px', + maxHeight: '300px', + overflowY: 'auto' as const, + display: 'flex', + flexDirection: 'column' as const, + gap: '8px', + }, + message: { + padding: '10px 14px', + borderRadius: '10px', + fontSize: '14px', + lineHeight: '1.5', + maxWidth: '85%', + }, + agentMessage: { + background: 'var(--surface-2)', + alignSelf: 'flex-start', + color: 'var(--text)', + }, + userMessage: { + background: 'var(--accent)', + alignSelf: 'flex-end', + color: 'white', + }, + inputRow: { + display: 'flex', + borderTop: '1px solid var(--border)', + }, + input: { + flex: 1, + padding: '12px 16px', + border: 'none', + background: 'transparent', + color: 'var(--text)', + fontSize: '14px', + outline: 'none', + fontFamily: 'inherit', + }, + sendButton: { + padding: '12px 20px', + border: 'none', + background: 'var(--accent)', + color: 'white', + fontSize: '14px', + fontWeight: '600', + cursor: 'pointer', + }, +}; diff --git a/ui/src/components/nav.tsx b/ui/src/components/nav.tsx new file mode 100644 index 0000000..002e498 --- /dev/null +++ b/ui/src/components/nav.tsx @@ -0,0 +1,44 @@ +interface Props { + title?: string; + back?: string; +} + +export function Nav({ title, back }: Props) { + return ( + + ); +} + +const styles = { + nav: { + borderBottom: '1px solid var(--border)', + background: 'var(--surface)', + }, + inner: { + maxWidth: '960px', + margin: '0 auto', + padding: '14px 24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + logo: { + fontSize: '18px', + fontWeight: '700', + color: 'var(--text)', + textDecoration: 'none', + }, + backLink: { + fontSize: '15px', + color: 'var(--text)', + textDecoration: 'none', + }, +}; diff --git a/ui/src/components/progress.tsx b/ui/src/components/progress.tsx new file mode 100644 index 0000000..96cb771 --- /dev/null +++ b/ui/src/components/progress.tsx @@ -0,0 +1,55 @@ +interface Milestone { + label: string; + status: 'done' | 'active' | 'pending'; +} + +interface Props { + milestones: Milestone[]; +} + +export function Progress({ milestones }: Props) { + return ( +
+ {milestones.map((m) => ( +
+ + {m.status === 'done' ? '\u2705' : m.status === 'active' ? '\u25D0' : '\u25CB'} + + + {m.label} + +
+ ))} +
+ ); +} + +function iconColor(status: string): string { + switch (status) { + case 'done': return 'var(--green)'; + case 'active': return 'var(--accent)'; + default: return 'var(--text-dim)'; + } +} + +const styles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + gap: '12px', + }, + item: { + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '15px', + }, + icon: { + fontSize: '16px', + width: '20px', + textAlign: 'center' as const, + }, +}; diff --git a/ui/src/components/prompt-input.tsx b/ui/src/components/prompt-input.tsx new file mode 100644 index 0000000..3fc0698 --- /dev/null +++ b/ui/src/components/prompt-input.tsx @@ -0,0 +1,77 @@ +import { useState } from 'preact/hooks'; + +interface Props { + onSubmit: (text: string) => void; + placeholder?: string; + buttonText?: string; + loading?: boolean; +} + +export function PromptInput({ onSubmit, placeholder, buttonText, loading }: Props) { + const [text, setText] = useState(''); + + const handleSubmit = (e: Event) => { + e.preventDefault(); + if (text.trim() && !loading) { + onSubmit(text.trim()); + setText(''); + } + }; + + return ( +
+