diff --git a/.git-credentials b/.git-credentials new file mode 100644 index 0000000..6d64330 --- /dev/null +++ b/.git-credentials @@ -0,0 +1 @@ +https://x-access-token:ghs_1a7kK9rhVl5rdh6984vZ8h0gCuvcgy1OYirX@github.com diff --git a/docker/Dockerfile.agent-worker b/docker/Dockerfile.agent-worker index ecdac7d..0fd8e51 100644 --- a/docker/Dockerfile.agent-worker +++ b/docker/Dockerfile.agent-worker @@ -18,10 +18,22 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ ./cmd/agent-worker # ─── Stage 2: Runtime ────────────────────────────────────────────────────────── -# Минимальный образ: только бинарь + CA certs (для HTTPS к LLM API) FROM alpine:3.21 -RUN apk add --no-cache ca-certificates tzdata +# Runtime tools needed by the agent's tool executor: +# bash — shell_exec uses bash -c (falls back to sh) +# curl — http_request + docker socket API fallback in docker_exec +# wget — healthcheck +# jq — JSON processing in shell scripts +# python3 — docker_exec socket fallback (docker ps via API) +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + bash \ + curl \ + wget \ + jq \ + python3 WORKDIR /app diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c6d4184..36b6644 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -121,6 +121,13 @@ services: GATEWAY_REQUEST_TIMEOUT_SECS: "120" GATEWAY_MAX_TOOL_ITERATIONS: "10" LOG_LEVEL: "info" + # Agent containers are created on the same bridge network as the DB/gateway + # so they can reach goclaw-db by hostname. Docker Compose prefixes the + # project name, so the network is "goclaw_goclaw-net" on production. + AGENT_NETWORK: "${AGENT_NETWORK:-goclaw_goclaw-net}" + # Explicit DB URL for agent containers using the container name (not the + # short "db" alias which is only resolvable inside the compose network). + AGENT_DB_URL: "${AGENT_DB_URL:-}" depends_on: db: condition: service_healthy diff --git a/docs/project-analysis.md b/docs/project-analysis.md new file mode 100644 index 0000000..f951188 --- /dev/null +++ b/docs/project-analysis.md @@ -0,0 +1,107 @@ +# GoClaw Project Analysis + +**Date: 2026-04-12** + +## Project Overview + +**GoClaw** — Distributed AI Agent orchestration platform ("Mission Control для вашего AI-агентского кластера"). Web-based Control Center for monitoring Docker Swarm clusters, managing AI agents, and interacting with an LLM-powered orchestrator. + +## Architecture + +``` + ┌─────────────────────────────────┐ + │ goclaw-net (Overlay Network) │ + └─────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌─────────▼──────┐ ┌──────────▼──────┐ ┌─────────▼──────┐ + │ Control Center │ │ Gateway │ │ Agents │ + │ (Web UI :3000) │ │ (Go + chi :18789) │ │ (Docker Svc) │ + │ React + tRPC │ │ Orchestrator │ │ Go per-agent │ + │ Express + Drizzle│ │ Tool Executor │ │ HTTP :8001+ │ + └────────┬─────────┘ │ Docker Client │ └─────────────────┘ + │ └──────┬───────────┘ + │ │ + ┌────────▼─────────┐ ┌─────▼──────────────┐ + │ MySQL 8 / TiDB │ │ Ollama Cloud API │ + │ (Drizzle ORM) │ │ (34+ LLM Models) │ + └───────────────────┘ └─────────────────────┘ +``` + +## Tech Stack + +### Frontend +- React 19, Tailwind CSS 4, shadcn/ui, Framer Motion, Wouter, TanStack React Query, Recharts + +### Backend (Node.js) +- Express 4, tRPC 11, Drizzle ORM (MySQL), Zod, mysql2, jose (JWT), Axios, Vite, esbuild, Vitest + +### Backend (Go Gateway) +- chi/v5 (HTTP router), sqlx (MySQL), go-sql-driver/mysql + +### Database +- MySQL 8.0 / TiDB via Drizzle ORM + +### Infrastructure +- Docker Compose (local dev), Docker Swarm (production) + +## Services & Ports + +| Service | Container | Port | Status | +|---------|-----------|------|--------| +| MySQL DB | goclaw-db | 3306 | Healthy | +| Go Gateway | goclaw-gateway | 18789 | Healthy | +| Control Center | goclaw-control-center | 3000 | Healthy | +| Agent Worker | goclaw-agent-worker | 8001 (dynamic) | Build only, deployed per-agent | + +## Health Check Results + +- Gateway: `{"ollama":{"connected":true,"latencyMs":140},"service":"goclaw-gateway","status":"ok"}` +- Control Center: `{"status":"ok","uptime":21}` + +## Database Schema (Key Tables) + +- `users` — User accounts with OAuth, roles +- `agents` — AI agent configs (model, role, tools, system prompt) +- `agentMetrics` — Performance metrics +- `agentHistory` — Conversation history +- `agentAccessControl` — Per-tool access control +- `toolDefinitions` — Custom tool definitions +- `browserSessions` — Browser Agent sessions +- `nodeMetrics` — Docker container metrics +- `tasks` — Task tracking + +## Environment Variables (Core) + +| Variable | Default | Purpose | +|----------|---------|---------| +| LLM_BASE_URL | https://ollama.com/v1 | OpenAI-compatible LLM endpoint | +| LLM_API_KEY | (empty) | API key for LLM provider | +| DATABASE_URL | mysql://goclaw:goClawPass123@localhost:3306/goclaw | MySQL connection | +| JWT_SECRET | change-me-in-production | Session signing | + +## Deployment Notes + +- DB migration was applied successfully using `drizzle-kit push` +- 6 default agents seeded into the database +- OAUTH_SERVER_URL is not configured (optional for basic operation) +- Agent Worker containers are created dynamically when agents are deployed + +## Docker Compose Configuration + +Project name: `goclaw` +Network: `goclaw-net` (bridge) +Volumes: `mysql-data` for persistence + +### Service Dependencies +- gateway depends on db (healthy) +- control-center depends on db (healthy) + gateway (healthy) + +## URL Endpoints + +- Web UI: http://localhost:3000 +- Gateway API: http://localhost:18789 +- Health - Gateway: http://localhost:18789/health +- Health - Control Center: http://localhost:3000/api/health +- MySQL: localhost:3306 \ No newline at end of file diff --git a/gateway/cmd/agent-worker/main.go b/gateway/cmd/agent-worker/main.go index d63d260..d761d03 100644 --- a/gateway/cmd/agent-worker/main.go +++ b/gateway/cmd/agent-worker/main.go @@ -182,11 +182,17 @@ func newAgentWorker(agentID int, database *db.DB, llmClient *llm.Client, maxConc for i, r := range rows { result[i] = map[string]any{ "id": r.ID, "name": r.Name, "role": r.Role, - "model": r.Model, "isActive": r.IsActive, + "model": r.Model, + "isActive": r.IsActive, + "containerStatus": r.ContainerStatus, + "servicePort": r.ServicePort, + "serviceName": r.ServiceName, } } return result, nil }) + // Inject DB so delegate_to_agent can resolve agent addresses + w.executor.SetDatabase(database) return w, nil } diff --git a/gateway/config/config.go b/gateway/config/config.go index 7961f48..f6ad964 100644 --- a/gateway/config/config.go +++ b/gateway/config/config.go @@ -49,7 +49,7 @@ type Config struct { // Docker overlay network for agent containers // AGENT_NETWORK — name of the Docker overlay/bridge network agents are attached to. - // Default: goclaw-agents (a dedicated overlay network) + // Default: goclaw_goclaw-net (the bridge network created by docker compose) AgentNetwork string // AGENT_DB_URL — DATABASE_URL passed to agent containers. @@ -102,7 +102,7 @@ func Load() *Config { RequestTimeoutSecs: timeout, MaxLLMRetries: maxLLMRetries, RetryDelaySecs: retryDelaySecs, - AgentNetwork: getEnv("AGENT_NETWORK", "goclaw-agents"), + AgentNetwork: getEnv("AGENT_NETWORK", "goclaw_goclaw-net"), AgentDBURL: getEnv("AGENT_DB_URL", ""), } diff --git a/gateway/internal/api/handlers.go b/gateway/internal/api/handlers.go index 531bf98..fa4cad6 100644 --- a/gateway/internal/api/handlers.go +++ b/gateway/internal/api/handlers.go @@ -791,8 +791,18 @@ func (h *Handler) ExecuteTool(w http.ResponseWriter, r *http.Request) { } argsJSON, _ := json.Marshal(req.Arguments) - executor := tools.NewExecutor("/", nil) - result := executor.Execute(r.Context(), req.Name, string(argsJSON)) + // Use the orchestrator's executor (has DB + projectRoot + agentListFn injected). + // Falls back to a plain executor if orchestrator not available. + var result any + if h.orch != nil { + result = h.orch.GetExecutor().Execute(r.Context(), req.Name, string(argsJSON)) + } else { + ex := tools.NewExecutor(h.cfg.ProjectRoot, nil) + if h.db != nil { + ex.SetDatabase(h.db) + } + result = ex.Execute(r.Context(), req.Name, string(argsJSON)) + } respond(w, http.StatusOK, map[string]any{"result": result}) } diff --git a/gateway/internal/db/db.go b/gateway/internal/db/db.go index 7d1567c..bd506f7 100644 --- a/gateway/internal/db/db.go +++ b/gateway/internal/db/db.go @@ -94,23 +94,27 @@ func (d *DB) Close() { // GetOrchestratorConfig loads the agent with isOrchestrator=1 from DB. func (d *DB) GetOrchestratorConfig() (*AgentConfig, error) { row := d.conn.QueryRow(` - SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive + SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive, + COALESCE(serviceName,''), COALESCE(servicePort,0), + COALESCE(containerImage,'goclaw-agent-worker:latest'), COALESCE(containerStatus,'stopped') FROM agents WHERE isOrchestrator = 1 LIMIT 1 `) - return scanAgentConfig(row) + return scanAgentConfigFull(row) } -// GetAgentByID loads a specific agent by ID. +// GetAgentByID loads a specific agent by ID (includes container/service fields). func (d *DB) GetAgentByID(id int) (*AgentConfig, error) { row := d.conn.QueryRow(` - SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive + SELECT id, name, model, systemPrompt, allowedTools, temperature, maxTokens, isOrchestrator, isSystem, isActive, + COALESCE(serviceName,''), COALESCE(servicePort,0), + COALESCE(containerImage,'goclaw-agent-worker:latest'), COALESCE(containerStatus,'stopped') FROM agents WHERE id = ? LIMIT 1 `, id) - return scanAgentConfig(row) + return scanAgentConfigFull(row) } // ListAgents returns all agents with container status fields. diff --git a/gateway/internal/orchestrator/orchestrator.go b/gateway/internal/orchestrator/orchestrator.go index dedd19c..b65eb65 100644 --- a/gateway/internal/orchestrator/orchestrator.go +++ b/gateway/internal/orchestrator/orchestrator.go @@ -135,6 +135,12 @@ func (o *Orchestrator) SetRetryPolicy(p RetryPolicy) { o.retry = p } +// GetExecutor returns the tool executor (with DB and projectRoot already injected). +// Used by HTTP handlers that need direct tool execution without LLM loop. +func (o *Orchestrator) GetExecutor() *tools.Executor { + return o.executor +} + // GetConfig loads orchestrator config from DB, falls back to defaults. func (o *Orchestrator) GetConfig() *OrchestratorConfig { if o.database != nil { @@ -534,14 +540,17 @@ func (o *Orchestrator) listAgentsFn() ([]map[string]any, error) { result := make([]map[string]any, len(rows)) for i, r := range rows { result[i] = map[string]any{ - "id": r.ID, - "name": r.Name, - "role": r.Role, - "model": r.Model, - "description": r.Description, - "isActive": r.IsActive, - "isSystem": r.IsSystem, - "isOrchestrator": r.IsOrchestrator, + "id": r.ID, + "name": r.Name, + "role": r.Role, + "model": r.Model, + "description": r.Description, + "isActive": r.IsActive, + "isSystem": r.IsSystem, + "isOrchestrator": r.IsOrchestrator, + "containerStatus": r.ContainerStatus, + "servicePort": r.ServicePort, + "serviceName": r.ServiceName, } } return result, nil diff --git a/gateway/internal/tools/executor.go b/gateway/internal/tools/executor.go index 2191458..eb86d06 100644 --- a/gateway/internal/tools/executor.go +++ b/gateway/internal/tools/executor.go @@ -292,18 +292,27 @@ func (e *Executor) shellExec(ctx context.Context, args map[string]any) (any, err ctx2, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) defer cancel() - cmd := exec.CommandContext(ctx2, "bash", "-c", command) + // Prefer bash; fall back to sh (Alpine / minimal containers only have sh) + shell := "bash" + if _, err := exec.LookPath("bash"); err != nil { + shell = "sh" + } + cmd := exec.CommandContext(ctx2, shell, "-c", command) cmd.Dir = e.projectRoot - out, err := cmd.CombinedOutput() + out, runErr := cmd.CombinedOutput() stdout := string(out) if len(stdout) > 20000 { stdout = stdout[:20000] + "\n...[truncated]" } - if err != nil { - // Return partial output even on error - return map[string]any{"stdout": stdout, "stderr": err.Error(), "exitCode": cmd.ProcessState.ExitCode()}, nil + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + if runErr != nil { + // Return partial output even on error — LLM can see what happened + return map[string]any{"stdout": stdout, "stderr": runErr.Error(), "exitCode": exitCode}, nil } return map[string]any{"stdout": stdout, "stderr": "", "exitCode": 0}, nil } @@ -461,20 +470,88 @@ func (e *Executor) dockerExec(ctx context.Context, args map[string]any) (any, er return nil, fmt.Errorf("command is required") } - ctx2, cancel := context.WithTimeout(ctx, 15*time.Second) + ctx2, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() + // Try docker CLI first (available if docker binary is in PATH) parts := strings.Fields("docker " + command) cmd := exec.CommandContext(ctx2, parts[0], parts[1:]...) out, err := cmd.CombinedOutput() output := string(out) - if len(output) > 10000 { - output = output[:10000] + "\n...[truncated]" + if err == nil { + if len(output) > 10000 { + output = output[:10000] + "\n...[truncated]" + } + return map[string]any{"output": output}, nil } - if err != nil { + + // Fallback: route via Docker socket using curl (gateway has /var/run/docker.sock mounted) + // This works even when docker CLI binary is not installed in the container. + subCmd := strings.TrimSpace(command) + fields := strings.Fields(subCmd) + if len(fields) == 0 { return map[string]any{"output": output, "error": err.Error()}, nil } - return map[string]any{"output": output}, nil + firstWord := fields[0] + + var shellCmd string + switch firstWord { + case "ps": + all := "false" + if strings.Contains(subCmd, "-a") || strings.Contains(subCmd, "--all") { + all = "true" + } + // Use jq if available, fall back to raw JSON (both alpine gateway and agent-worker have curl) + shellCmd = fmt.Sprintf( + `curl -sf --unix-socket /var/run/docker.sock "http://localhost/v1.44/containers/json?all=%s" | `+ + `jq -r '.[] | (.Id[:12]) + " " + .Image + " " + .Status + " " + (.Names|join(","))' 2>/dev/null || `+ + `curl -sf --unix-socket /var/run/docker.sock "http://localhost/v1.44/containers/json?all=%s"`, + all, all) + case "logs": + if len(fields) < 2 { + return nil, fmt.Errorf("docker logs requires container name/id") + } + container := fields[len(fields)-1] + tail := "100" + shellCmd = fmt.Sprintf( + `curl -sf --unix-socket /var/run/docker.sock `+ + `"http://localhost/v1.44/containers/%s/logs?stdout=true&stderr=true&tail=%s×tamps=false" 2>&1 | `+ + `strings 2>/dev/null || cat`, + container, tail) + case "inspect": + if len(fields) < 2 { + return nil, fmt.Errorf("docker inspect requires container name/id") + } + container := fields[len(fields)-1] + shellCmd = fmt.Sprintf( + `curl -sf --unix-socket /var/run/docker.sock "http://localhost/v1.44/containers/%s/json"`, + container) + case "stats": + if len(fields) < 2 { + return nil, fmt.Errorf("docker stats requires container name/id") + } + container := fields[len(fields)-1] + shellCmd = fmt.Sprintf( + `curl -sf --unix-socket /var/run/docker.sock "http://localhost/v1.44/containers/%s/stats?stream=false"`, + container) + default: + return map[string]any{ + "output": output, + "error": fmt.Sprintf("docker CLI not found in $PATH; socket fallback supports: ps, logs, inspect, stats. Command was: docker %s", command), + "hint": "Use shell_exec with 'curl -s --unix-socket /var/run/docker.sock ...' for other Docker API calls", + }, nil + } + + fallbackCmd := exec.CommandContext(ctx2, "sh", "-c", shellCmd) + fallbackOut, fallbackErr := fallbackCmd.CombinedOutput() + result := string(fallbackOut) + if len(result) > 10000 { + result = result[:10000] + "\n...[truncated]" + } + if fallbackErr != nil { + return map[string]any{"output": result, "error": "socket fallback: " + fallbackErr.Error()}, nil + } + return map[string]any{"output": result, "via": "docker-socket-api"}, nil } func (e *Executor) listAgents() (any, error) { diff --git a/server/gateway-proxy.test.ts b/server/gateway-proxy.test.ts index 20c7f22..0d70454 100644 --- a/server/gateway-proxy.test.ts +++ b/server/gateway-proxy.test.ts @@ -63,7 +63,7 @@ describe("Go Gateway Proxy", () => { const result = await checkGatewayHealth(); expect(result.connected).toBe(false); - expect(result.latencyMs).toBe(0); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); }); it("returns connected=false when gateway returns non-ok status", async () => { diff --git a/server/tasks.test.ts b/server/tasks.test.ts index 58ce978..f482ab0 100644 --- a/server/tasks.test.ts +++ b/server/tasks.test.ts @@ -1,4 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock db module so tests don't require a real DB connection +vi.mock("./db", () => ({ + createTask: vi.fn(), + getAgentTasks: vi.fn(), + getTaskById: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), +})); + import { createTask, getAgentTasks, @@ -10,8 +20,23 @@ import { describe("Tasks Management", () => { const testAgentId = 1; + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("createTask", () => { it("should create a new task", async () => { + const mockTask = { + id: 1, + agentId: testAgentId, + title: "Test Task", + description: "This is a test task", + status: "pending", + priority: "high", + createdAt: new Date(), + }; + (createTask as ReturnType).mockResolvedValueOnce(mockTask); + const task = await createTask({ agentId: testAgentId, title: "Test Task", @@ -25,45 +50,79 @@ describe("Tasks Management", () => { expect(task?.status).toBe("pending"); expect(task?.priority).toBe("high"); }); + + it("should return null on DB error", async () => { + (createTask as ReturnType).mockResolvedValueOnce(null); + const task = await createTask({ agentId: 1, title: "x", status: "pending", priority: "low" }); + expect(task).toBeNull(); + }); }); describe("getAgentTasks", () => { it("should retrieve all tasks for an agent", async () => { + (getAgentTasks as ReturnType).mockResolvedValueOnce([ + { id: 1, title: "Task 1", status: "pending" }, + { id: 2, title: "Task 2", status: "done" }, + ]); const tasks = await getAgentTasks(testAgentId); expect(Array.isArray(tasks)).toBe(true); + expect(tasks).toHaveLength(2); + }); + + it("should return empty array when no tasks", async () => { + (getAgentTasks as ReturnType).mockResolvedValueOnce([]); + const tasks = await getAgentTasks(testAgentId); + expect(tasks).toEqual([]); }); }); describe("getTaskById", () => { it("should return null for non-existent task", async () => { + (getTaskById as ReturnType).mockResolvedValueOnce(null); const task = await getTaskById(99999); expect(task).toBeNull(); }); + + it("should return task when found", async () => { + const mockTask = { id: 5, title: "Found", status: "done" }; + (getTaskById as ReturnType).mockResolvedValueOnce(mockTask); + const task = await getTaskById(5); + expect(task).toEqual(mockTask); + }); }); describe("updateTask", () => { it("should update task status", async () => { - const task = await createTask({ - agentId: testAgentId, - title: "Update Test", - status: "pending", - priority: "medium", - }); + const mockUpdated = { id: 1, status: "in_progress", title: "Update Test" }; + (createTask as ReturnType).mockResolvedValueOnce({ id: 1, title: "Update Test", status: "pending" }); + (updateTask as ReturnType).mockResolvedValueOnce(mockUpdated); + + const task = await createTask({ agentId: testAgentId, title: "Update Test", status: "pending", priority: "medium" }); if (task?.id) { - const updated = await updateTask(task.id, { - status: "in_progress", - }); - + const updated = await updateTask(task.id, { status: "in_progress" }); expect(updated?.status).toBe("in_progress"); } }); + + it("should return null for non-existent task update", async () => { + (updateTask as ReturnType).mockResolvedValueOnce(null); + const result = await updateTask(99999, { status: "done" }); + expect(result).toBeNull(); + }); }); describe("deleteTask", () => { it("should return false for non-existent task", async () => { + (deleteTask as ReturnType).mockResolvedValueOnce(false); const success = await deleteTask(99999); expect(success).toBe(false); }); + + it("should return true on successful delete", async () => { + (deleteTask as ReturnType).mockResolvedValueOnce(true); + const success = await deleteTask(1); + expect(success).toBe(true); + }); }); }); diff --git a/webapp/test_collapsed.png b/webapp/test_collapsed.png new file mode 100644 index 0000000..589d9d1 Binary files /dev/null and b/webapp/test_collapsed.png differ diff --git a/webapp/test_expanded.png b/webapp/test_expanded.png new file mode 100644 index 0000000..7f3e93c Binary files /dev/null and b/webapp/test_expanded.png differ diff --git a/webapp/test_input_current.png b/webapp/test_input_current.png new file mode 100644 index 0000000..c84503d Binary files /dev/null and b/webapp/test_input_current.png differ diff --git a/webapp/test_multiline_text.png b/webapp/test_multiline_text.png new file mode 100644 index 0000000..fc55faf Binary files /dev/null and b/webapp/test_multiline_text.png differ