From 81277368c14182cb65551fec9f1c246075299e23 Mon Sep 17 00:00:00 2001 From: justgithubaccount <86586195+justgithubaccount@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:59:18 +0000 Subject: [PATCH 1/3] feat: add validated CrewAI service boundary --- .env.example | 5 + .../plans/2026-10-29-automation.md | 74 +++++ .../specs/2026-10-29-automation-design.md | 57 ++++ src/app/api/automation/[...path]/route.ts | 14 + src/lib/api/automation-proxy.ts | 48 ++++ src/lib/api/automation-schemas.ts | 52 ++++ src/lib/api/automation.test.ts | 256 ++++++++++++++++++ src/lib/api/client.ts | 17 +- src/lib/api/proxy.ts | 102 +------ src/lib/api/upstream-proxy.ts | 105 +++++++ 10 files changed, 639 insertions(+), 91 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-29-automation.md create mode 100644 docs/superpowers/specs/2026-10-29-automation-design.md create mode 100644 src/app/api/automation/[...path]/route.ts create mode 100644 src/lib/api/automation-proxy.ts create mode 100644 src/lib/api/automation-schemas.ts create mode 100644 src/lib/api/automation.test.ts create mode 100644 src/lib/api/upstream-proxy.ts diff --git a/.env.example b/.env.example index 6cdc3a0..1a62ef7 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ CHAT_API_URL=http://127.0.0.1:8000 # The backend LLM request has a 60-second deadline. CHAT_API_TIMEOUT_MS=70000 + +# Optional, server-only CrewAI service. Leave unset until it is available. +# CREWAI_API_URL=http://127.0.0.1:8001 +# Flows may take longer than chat. A timeout does not cancel a running flow. +CREWAI_API_TIMEOUT_MS=120000 diff --git a/docs/superpowers/plans/2026-10-29-automation.md b/docs/superpowers/plans/2026-10-29-automation.md new file mode 100644 index 0000000..5d94443 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-automation.md @@ -0,0 +1,74 @@ +# Automation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose the existing CrewAI API through the shared UI. + +**Architecture:** Separate `/automation` feature and `/api/automation` proxy. +Reuse shared transport, error validation and semantic components; preserve Chat API behavior. + +**Tech Stack:** Existing Next.js, React, TypeScript, Zod, SWR, Vitest and Playwright; no new dependencies. + +**Spec:** `docs/superpowers/specs/2026-10-29-automation-design.md` + +## Global Constraints + +- Mutation target: `justgithubaccount/app-poly-gitops-ui` only; delivery through a CI-verified PR, no merge/deployment. +- No backend mutation, real flow run, paid provider call, browser credential forwarding or proprietary references. +- Manual confirmed execution only; timeout never means cancellation; results are not persistent history. +- `CREWAI_API_URL` is server-only and unset by default; deadline defaults to 120000ms with range 1000–300000ms. +- Preserve existing Chat API behavior; no new dependencies; responsive at 320/390/1440px. + +## Review Focus + +- Context overriding visible namespace/app name must be rejected (Task 1). +- A timeout may leave upstream work running; never auto-retry or imply cancellation (Tasks 1/2). +- Missing/unavailable CrewAI must not disable existing chat/projects (Tasks 1/2). +- Long flow names, outputs and metadata must not overflow mobile (Task 2). +- Stale catalog refresh must not clear valid data or masquerade as empty (Task 2). + +### Task 1: Validated service boundary + +**Files:** + +- Create: `src/lib/api/automation-schemas.ts`, `automation-proxy.ts`, `upstream-proxy.ts` and boundary tests. +- Modify: `src/lib/api/client.ts`, `proxy.ts`, `.env.example`. +- Create: `src/app/api/automation/[...path]/route.ts`. + +**Interfaces:** + +- Produces: `automationRequest(path, schema, init?)`, `flowsSchema`, `agentsSchema`, `runRequestSchema`, `runResponseSchema`, `parseRunContext(text)`. +- Produces: `automationPath(method, segments): string | null`, `proxyAutomationRequest(request, segments, fetcher?, env?): Promise`. +- Preserves: `apiRequest`, `backendPath`, `proxyBackendRequest` and their existing signatures/defaults. + +- [ ] Write failing tests for exact routes, JSON/target validation, response validation, fixed destination, no forwarded credentials, missing config, origin guard, timeout/no retry and separate service client paths. +- [ ] Run `npm test`; expected new tests fail on missing boundary behavior. +- [ ] Implement strict schemas, shared proxy/client transport and independent automation route/configuration. +- [ ] Run `npm test`; expected all tests pass, including existing Chat API guards. +- [ ] Commit the validated boundary. + +### Task 2: Automation workspace + +**Files:** + +- Create: `src/features/automation/{automation-workspace,automation-catalog,run-form,run-result}.tsx`. +- Create: `src/app/automation/page.tsx`, `tests/browser/automation.spec.ts`. +- Modify: shell/icons, existing responsive browser test, README, project instructions and foundation documentation. + +**Interfaces:** + +- Consumes: Task 1 schemas/client and shared `resolveAsyncView`, layout/data-state components. +- Produces: `AutomationWorkspace`, independent catalog regions, validated manual form and `RunResult`. + +- [ ] Write failing browser tests for discovery/empty/error states, acknowledgement, named/default run, JSON validation, pending/result/error handling, preserved drafts, stale catalog, no automatic POST retry, and long content at mobile widths. +- [ ] Run the new browser test against a clean baseline server; expected missing navigation/workspace assertions fail. +- [ ] Implement the workspace, navigation and documentation. Use native labelled select/checkbox inside existing shared form patterns; no unneeded primitive dependency. +- [ ] Run check/build/browser suite and formatting/audit; expected all pass without regressions. +- [ ] Exercise the real proxy against an explicit local HTTP fixture, inspect desktop/mobile rendering and document the evidence limits. +- [ ] Commit the workspace and verification record. + +### Delivery + +- [ ] Review the whole diff and obtain a fresh-context review; resolve important findings with regression tests. +- [ ] Push `feat/crewai-automation`, create a PR and verify CI for the exact head. +- [ ] Keep the user's preview available with honest missing-service state; do not merge or deploy. diff --git a/docs/superpowers/specs/2026-10-29-automation-design.md b/docs/superpowers/specs/2026-10-29-automation-design.md new file mode 100644 index 0000000..457a144 --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-automation-design.md @@ -0,0 +1,57 @@ +# Automation workspace + +## Intent and approved scope + +Add the existing CrewAI API to this UI as a separate Automation workspace. +The operator approved the existing-API proposal: flow/agent discovery, explicit +manual execution and inspection of returned results. Backend services remain +separate. Only `justgithubaccount/app-poly-gitops-ui` is a mutation target. + +## Approaches + +1. **Selected:** a separate `/automation` workspace and fixed-upstream proxy. + Reuses the application shell, semantic tokens, read states and API validation. +2. Merge the services or introduce a gateway: unnecessary backend changes. +3. Add an independent frontend: duplicates setup and fragments the workspace. + +## API and interface + +Verified source: `app-poly-gitops-crewai@0835d3102555e6cef4fb700c56969ecddc6f6ddd`, +`src/auto_k8s_pilot/api.py` and `flow_runner.py`. + +- `GET /health`, `/flows`, `/agents`: independent regions with loading, empty, + error, refresh and stale states. An unavailable CrewAI must not break chat. +- `POST /run` and `/run/{flow_name}`: select the server default or a discovered + flow; enter namespace, optional app name and optional JSON object context. +- Before each run require an explicit acknowledgement that flows may call an + LLM, change infrastructure or create issues. Never start on load or retry POST. +- Show pending state while waiting, then final answer, ordered step outputs, + agents/tasks, durations, metadata and trace ID if supplied. Results are only + retained in the mounted workspace. They are not a persisted run history. +- Failures preserve the form. Timeout/disconnect means execution may continue; + another run requires fresh acknowledgement. No cancellation/progress promises. +- Reject context keys `namespace` and `app_name`: the API merges context last, + so accepting them would silently override the visible target fields. +- Mobile navigation must wrap and all content must fit at 320/390/1440px. + +## Boundary and configuration + +Server-only `CREWAI_API_URL` (unset by default) and +`CREWAI_API_TIMEOUT_MS=120000`, allowed range 1000–300000ms. Browser calls only +`/api/automation/...`. Preserve the existing Chat API configuration/defaults. +Share transport/error handling; maintain separate route/body allowlists. +No arbitrary URLs/paths, browser credentials, upstream redirects or automatic +execution. Unsupported routes/methods, invalid names, non-object JSON context, +cross-origin writes and excessive payloads are rejected before upstream calls. + +## Non-goals and validation + +No backend mutation, deployment, paid LLM call or real flow execution is part of +implementation verification. No run persistence, live streaming, cancellation, +editable agents/flows, writable provider settings or authentication is invented. +Source-public is not runtime-public: the current services have no user boundary. + +Verify unit contracts/proxy guards and browser discovery, confirmed execution, +result inspection, errors, no auto-retry, missing configuration, stale lists and +responsive layout. Also exercise the real UI proxy against a loopback HTTP +fixture; keep fixture evidence separate from real CrewAI execution. diff --git a/src/app/api/automation/[...path]/route.ts b/src/app/api/automation/[...path]/route.ts new file mode 100644 index 0000000..fa00171 --- /dev/null +++ b/src/app/api/automation/[...path]/route.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { proxyAutomationRequest } from "@/lib/api/automation-proxy"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ path: string[] }> }; + +async function handle(request: Request, context: RouteContext) { + const { path } = await context.params; + return proxyAutomationRequest(request, path); +} + +export const GET = handle; +export const POST = handle; diff --git a/src/lib/api/automation-proxy.ts b/src/lib/api/automation-proxy.ts new file mode 100644 index 0000000..14aadc6 --- /dev/null +++ b/src/lib/api/automation-proxy.ts @@ -0,0 +1,48 @@ +import { flowNameSchema, runRequestSchema } from "./automation-schemas"; +import { proxyUpstreamRequest } from "./upstream-proxy"; + +export function automationPath(method: string, segments: string[]): string | null { + if (segments.length === 1) { + if (method === "GET" && ["health", "flows", "agents"].includes(segments[0])) { + return `/${segments[0]}`; + } + if (method === "POST" && segments[0] === "run") return "/run"; + } + if ( + method === "POST" && + segments.length === 2 && + segments[0] === "run" && + flowNameSchema.safeParse(segments[1]).success + ) { + return `/run/${segments[1]}`; + } + return null; +} + +type AutomationEnvironment = { + CREWAI_API_URL?: string; + CREWAI_API_TIMEOUT_MS?: string; +}; + +export function proxyAutomationRequest( + request: Request, + segments: string[], + fetcher: typeof fetch = fetch, + env: AutomationEnvironment = { + CREWAI_API_URL: process.env.CREWAI_API_URL, + CREWAI_API_TIMEOUT_MS: process.env.CREWAI_API_TIMEOUT_MS, + }, +): Promise { + return proxyUpstreamRequest( + request, + { + path: automationPath(request.method, segments), + bodySchema: runRequestSchema, + url: env.CREWAI_API_URL, + timeout: env.CREWAI_API_TIMEOUT_MS, + defaultTimeout: 120000, + maxTimeout: 300000, + }, + fetcher, + ); +} diff --git a/src/lib/api/automation-schemas.ts b/src/lib/api/automation-schemas.ts new file mode 100644 index 0000000..16a67a7 --- /dev/null +++ b/src/lib/api/automation-schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const flowNameSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/); +export const flowsSchema = z.object({ flows: z.array(flowNameSchema) }); +export const agentsSchema = z.object({ agents: z.array(z.string().min(1)) }); + +const contextSchema = z + .record(z.string(), z.unknown()) + .refine((value) => !Object.hasOwn(value, "namespace") && !Object.hasOwn(value, "app_name"), { + message: "namespace и app_name задаются отдельными полями, а не через контекст", + }); + +export const runRequestSchema = z.object({ + namespace: z.string().trim().min(1).max(253), + app_name: z.string().trim().min(1).max(253).nullable().optional(), + context: contextSchema.nullable().optional(), +}); + +export function parseRunContext(text: string): z.infer | undefined { + if (!text.trim()) return undefined; + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error("Контекст должен быть корректным JSON-объектом"); + } + const parsed = contextSchema.safeParse(value); + if (!parsed.success) { + throw new Error("Контекст должен быть JSON-объектом без ключей namespace и app_name"); + } + return parsed.data; +} + +export const runResponseSchema = z.object({ + final_answer: z.string(), + steps: z.array( + z.object({ + agent: z.string(), + task: z.string(), + output: z.string(), + duration_ms: z.number().int().nonnegative(), + }), + ), + metadata: z + .object({ + total_duration_ms: z.number().nonnegative().optional(), + trace_id: z.string().optional(), + }) + .catchall(z.unknown()), +}); + +export type RunResponse = z.infer; diff --git a/src/lib/api/automation.test.ts b/src/lib/api/automation.test.ts new file mode 100644 index 0000000..db93473 --- /dev/null +++ b/src/lib/api/automation.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { automationRequest } from "./client"; +import { automationPath, proxyAutomationRequest } from "./automation-proxy"; +import { + agentsSchema, + flowsSchema, + parseRunContext, + runRequestSchema, + runResponseSchema, +} from "./automation-schemas"; + +const env = { CREWAI_API_URL: "http://127.0.0.1:8001" }; +const result = { + final_answer: "Cluster summary", + steps: [{ agent: "observer", task: "pods", output: "Ready", duration_ms: 12 }], + metadata: { total_duration_ms: 15, trace_id: "0123456789abcdef0123456789abcdef" }, +}; +const post = (body: unknown, headers: Record = {}) => + new Request("http://localhost:3100/api/automation/run/infra-health", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + +afterEach(() => vi.unstubAllGlobals()); + +describe("automation contracts", () => { + it("validates actual discovery/results without fabricating missing fields", () => { + expect(flowsSchema.parse({ flows: ["infra-health"] }).flows).toEqual(["infra-health"]); + expect(agentsSchema.parse({ agents: ["observer"] }).agents).toEqual(["observer"]); + expect(runResponseSchema.parse(result).steps[0].duration_ms).toBe(12); + expect(runResponseSchema.safeParse({ final_answer: "ok" }).success).toBe(false); + expect(runResponseSchema.safeParse({ ...result, steps: [{ output: "ok" }] }).success).toBe( + false, + ); + expect(flowsSchema.safeParse({ flows: ["../private"] }).success).toBe(false); + }); + + it("accepts optional object context but rejects arrays, scalars and target overrides", () => { + expect(parseRunContext(" ")).toBeUndefined(); + expect(parseRunContext('{"question":"Why?","limit":4}')).toEqual({ + question: "Why?", + limit: 4, + }); + for (const input of [ + "[]", + "null", + '"text"', + "{", + '{"namespace":"other"}', + '{"app_name":"other"}', + ]) { + expect(() => parseRunContext(input)).toThrow(); + } + expect(runRequestSchema.parse({ namespace: " prod ", app_name: "chat-api" })).toEqual({ + namespace: "prod", + app_name: "chat-api", + }); + for (const context of [{ namespace: "other" }, { app_name: "other" }, []]) { + expect(runRequestSchema.safeParse({ namespace: "prod", context }).success).toBe(false); + } + expect(runRequestSchema.safeParse({ namespace: " " }).success).toBe(false); + }); + + it("uses the automation client path and preserves FastAPI errors", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ flows: ["infra-health"] })) + .mockResolvedValueOnce( + Response.json({ detail: [{ msg: "Invalid namespace" }] }, { status: 422 }), + ) + .mockResolvedValueOnce(Response.json({ flows: "invalid" })); + vi.stubGlobal("fetch", fetcher); + expect(await automationRequest("flows", flowsSchema)).toEqual({ flows: ["infra-health"] }); + expect(fetcher.mock.calls[0][0]).toBe("/api/automation/flows"); + await expect( + automationRequest("run", runResponseSchema, { method: "POST", body: "{}" }), + ).rejects.toThrow("Invalid namespace"); + await expect(automationRequest("flows", flowsSchema)).rejects.toThrow( + "Ответ API не соответствует контракту", + ); + }); +}); + +describe("bounded automation proxy", () => { + it("routes the five existing endpoints and nothing outside that contract", () => { + for (const name of ["health", "flows", "agents"]) + expect(automationPath("GET", [name])).toBe(`/${name}`); + expect(automationPath("POST", ["run"])).toBe("/run"); + expect(automationPath("POST", ["run", "infra-health"])).toBe("/run/infra-health"); + for (const path of [ + ["..", "health"], + ["run", ".."], + ["run", "a/b"], + ["run", "a?token=1"], + ["run", "a%2Fb"], + ["run", "x", "extra"], + ["openapi.json"], + ]) { + expect(automationPath("POST", path)).toBeNull(); + } + expect(automationPath("GET", ["run"])).toBeNull(); + expect(automationPath("DELETE", ["flows"])).toBeNull(); + }); + + it("forwards validated targets only to the configured CrewAI service", async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json(result)); + const response = await proxyAutomationRequest( + post( + { namespace: " prod ", app_name: "chat-api", context: { question: "Why?" } }, + { + cookie: "private=token", + authorization: "Bearer private", + origin: "http://localhost:3100", + }, + ), + ["run", "infra-health"], + fetcher, + env, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(result); + const [url, init] = fetcher.mock.calls[0]; + expect(String(url)).toBe("http://127.0.0.1:8001/run/infra-health"); + expect(JSON.parse(init?.body as string)).toEqual({ + namespace: "prod", + app_name: "chat-api", + context: { question: "Why?" }, + }); + expect(init?.headers).toEqual({ + Accept: "application/json", + "Content-Type": "application/json", + }); + expect(init?.redirect).toBe("error"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("supports server-default execution without guessing the default flow", async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json(result)); + expect( + (await proxyAutomationRequest(post({ namespace: "default" }), ["run"], fetcher, env)).status, + ).toBe(200); + expect(String(fetcher.mock.calls[0][0])).toBe("http://127.0.0.1:8001/run"); + }); + + it("rejects invalid JSON targets/context, oversized bodies and cross-origin writes before upstream", async () => { + const fetcher = vi.fn(); + for (const body of [ + { namespace: "" }, + { namespace: "prod", context: [] }, + { namespace: "prod", context: { namespace: "secret" } }, + { namespace: "prod", context: { app_name: "other" } }, + ]) { + expect((await proxyAutomationRequest(post(body), ["run"], fetcher, env)).status).toBe(422); + } + expect( + ( + await proxyAutomationRequest( + post({ namespace: "prod" }, { origin: "https://other.test" }), + ["run"], + fetcher, + env, + ) + ).status, + ).toBe(403); + expect( + ( + await proxyAutomationRequest( + post({ namespace: "prod" }, { "content-type": "text/plain" }), + ["run"], + fetcher, + env, + ) + ).status, + ).toBe(415); + expect( + ( + await proxyAutomationRequest( + new Request("http://localhost/api/automation/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }), + ["run"], + fetcher, + env, + ) + ).status, + ).toBe(400); + expect( + ( + await proxyAutomationRequest( + post({ namespace: "prod", context: { text: "x".repeat(1_000_001) } }), + ["run"], + fetcher, + env, + ) + ).status, + ).toBe(413); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("keeps missing/invalid CrewAI configuration local and does not disclose it", async () => { + const fetcher = vi.fn(); + for (const config of [ + {}, + { CREWAI_API_URL: "file:///etc/passwd" }, + { CREWAI_API_URL: "https://user:secret@example.test" }, + { ...env, CREWAI_API_TIMEOUT_MS: "999999" }, + ]) { + const response = await proxyAutomationRequest( + new Request("http://localhost/api/automation/flows"), + ["flows"], + fetcher, + config, + ); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain("secret"); + } + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("never retries a timeout or claims that upstream execution stopped", async () => { + const fetcher = vi + .fn() + .mockRejectedValue(new DOMException("timeout", "TimeoutError")); + const response = await proxyAutomationRequest( + post({ namespace: "default" }), + ["run"], + fetcher, + env, + ); + expect(response.status).toBe(504); + expect(await response.text()).toContain("мог продолжить выполнение"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("reports unreachable and non-JSON upstream replies honestly", async () => { + for (const failure of [ + vi.fn().mockRejectedValue(new Error("offline")), + vi + .fn() + .mockResolvedValue(new Response("bad gateway", { status: 502 })), + ]) { + const response = await proxyAutomationRequest( + new Request("http://localhost/api/automation/flows"), + ["flows"], + failure, + env, + ); + expect(response.status).toBe(502); + expect(await response.text()).toContain("недоступен"); + } + }); +}); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 08b5969..c6c785f 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -22,12 +22,13 @@ function errorDetail(body: unknown, status: number): string { return `API вернул ошибку ${status}`; } -export async function apiRequest( +async function requestService( + service: "backend" | "automation", path: string, schema: z.ZodType, init?: RequestInit, ): Promise { - const response = await fetch(`/api/backend/${path}`, { + const response = await fetch(`/api/${service}/${path}`, { ...init, headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...init?.headers }, cache: "no-store", @@ -38,3 +39,15 @@ export async function apiRequest( if (!parsed.success) throw new ApiError("Ответ API не соответствует контракту", 502); return parsed.data; } + +export function apiRequest(path: string, schema: z.ZodType, init?: RequestInit): Promise { + return requestService("backend", path, schema, init); +} + +export function automationRequest( + path: string, + schema: z.ZodType, + init?: RequestInit, +): Promise { + return requestService("automation", path, schema, init); +} diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index 163a82d..80bf8c2 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; import { chatRequestSchema, createProjectSchema } from "./schemas"; +import { proxyUpstreamRequest } from "./upstream-proxy"; const projectId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -17,18 +17,6 @@ export function backendPath(method: string, segments: string[]): string | null { return null; } -const upstreamSchema = z.url().refine((value) => { - const url = new URL(value); - return ( - ["http:", "https:"].includes(url.protocol) && - !url.username && - !url.password && - !url.search && - !url.hash && - url.pathname === "/" - ); -}); - type ProxyEnvironment = { CHAT_API_URL?: string; CHAT_API_TIMEOUT_MS?: string; @@ -44,80 +32,16 @@ export async function proxyBackendRequest( }, ): Promise { const path = backendPath(request.method, segments); - if (!path) return Response.json({ detail: "Маршрут API не поддерживается" }, { status: 404 }); - - const configured = upstreamSchema.safeParse(env.CHAT_API_URL); - const timeout = z.coerce - .number() - .int() - .min(1000) - .max(120000) - .safeParse(env.CHAT_API_TIMEOUT_MS ?? "70000"); - if (!configured.success || !timeout.success) { - return Response.json( - { detail: "Подключение к API не настроено на сервере UI" }, - { status: 503 }, - ); - } - - let body: string | undefined; - if (request.method === "POST") { - const origin = request.headers.get("origin"); - const requestUrl = new URL(request.url); - // Next can reconstruct Request.url with its internal hostname. The Host - // header retains the authority actually requested by the browser. - const host = request.headers.get("host") ?? requestUrl.host; - const expectedOrigin = new URL(`${requestUrl.protocol}//${host}`).origin; - if (origin && origin !== expectedOrigin) { - return Response.json({ detail: "Недопустимый источник запроса" }, { status: 403 }); - } - if (!request.headers.get("content-type")?.startsWith("application/json")) { - return Response.json({ detail: "Требуется application/json" }, { status: 415 }); - } - const raw = await request.text(); - if (raw.length > 1_000_000) { - return Response.json({ detail: "Запрос слишком большой" }, { status: 413 }); - } - let input: unknown; - try { - input = JSON.parse(raw); - } catch { - return Response.json({ detail: "Некорректный JSON" }, { status: 400 }); - } - const schema = path === "/api/v1/projects" ? createProjectSchema : chatRequestSchema; - const parsed = schema.safeParse(input); - if (!parsed.success) { - return Response.json({ detail: "Проверьте поля запроса" }, { status: 422 }); - } - body = JSON.stringify(parsed.data); - } - - try { - const response = await fetcher(new URL(path, configured.data), { - method: request.method, - headers: { - Accept: "application/json", - ...(body ? { "Content-Type": "application/json" } : {}), - }, - body, - cache: "no-store", - redirect: "error", - signal: AbortSignal.timeout(timeout.data), - }); - const payload: unknown = await response.json(); - return Response.json(payload, { - status: response.status, - headers: { "Cache-Control": "no-store" }, - }); - } catch (error) { - const timedOut = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name); - return Response.json( - { - detail: timedOut - ? "API не ответил вовремя. Запрос мог продолжить выполнение." - : "API сейчас недоступен", - }, - { status: timedOut ? 504 : 502 }, - ); - } + return proxyUpstreamRequest( + request, + { + path, + bodySchema: path === "/api/v1/projects" ? createProjectSchema : chatRequestSchema, + url: env.CHAT_API_URL, + timeout: env.CHAT_API_TIMEOUT_MS, + defaultTimeout: 70000, + maxTimeout: 120000, + }, + fetcher, + ); } diff --git a/src/lib/api/upstream-proxy.ts b/src/lib/api/upstream-proxy.ts new file mode 100644 index 0000000..6f45120 --- /dev/null +++ b/src/lib/api/upstream-proxy.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; + +const upstreamSchema = z.url().refine((value) => { + const url = new URL(value); + return ( + ["http:", "https:"].includes(url.protocol) && + !url.username && + !url.password && + !url.search && + !url.hash && + url.pathname === "/" + ); +}); + +type UpstreamOptions = { + path: string | null; + bodySchema?: z.ZodType; + url?: string; + timeout?: string; + defaultTimeout: number; + maxTimeout: number; +}; + +// Transport is shared; each service supplies its own route/body allowlist. +export async function proxyUpstreamRequest( + request: Request, + options: UpstreamOptions, + fetcher: typeof fetch, +): Promise { + if (!options.path) { + return Response.json({ detail: "Маршрут API не поддерживается" }, { status: 404 }); + } + const configured = upstreamSchema.safeParse(options.url); + const timeout = z.coerce + .number() + .int() + .min(1000) + .max(options.maxTimeout) + .safeParse(options.timeout ?? options.defaultTimeout); + if (!configured.success || !timeout.success) { + return Response.json( + { detail: "Подключение к API не настроено на сервере UI" }, + { status: 503 }, + ); + } + + let body: string | undefined; + if (request.method === "POST") { + const origin = request.headers.get("origin"); + const requestUrl = new URL(request.url); + // Next may use an internal hostname; Host retains the browser authority. + const host = request.headers.get("host") ?? requestUrl.host; + const expectedOrigin = new URL(`${requestUrl.protocol}//${host}`).origin; + if (origin && origin !== expectedOrigin) { + return Response.json({ detail: "Недопустимый источник запроса" }, { status: 403 }); + } + if (!request.headers.get("content-type")?.startsWith("application/json")) { + return Response.json({ detail: "Требуется application/json" }, { status: 415 }); + } + const raw = await request.text(); + if (raw.length > 1_000_000) { + return Response.json({ detail: "Запрос слишком большой" }, { status: 413 }); + } + let input: unknown; + try { + input = JSON.parse(raw); + } catch { + return Response.json({ detail: "Некорректный JSON" }, { status: 400 }); + } + const parsed = options.bodySchema?.safeParse(input); + if (!parsed?.success) { + return Response.json({ detail: "Проверьте поля запроса" }, { status: 422 }); + } + body = JSON.stringify(parsed.data); + } + + try { + const response = await fetcher(new URL(options.path, configured.data), { + method: request.method, + headers: { + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(timeout.data), + }); + const payload: unknown = await response.json(); + return Response.json(payload, { + status: response.status, + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + const timedOut = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name); + return Response.json( + { + detail: timedOut + ? "API не ответил вовремя. Запрос мог продолжить выполнение." + : "API сейчас недоступен", + }, + { status: timedOut ? 504 : 502 }, + ); + } +} From 2852b17530f7eac756c3e4b6a3e35b1ef8f7837b Mon Sep 17 00:00:00 2001 From: justgithubaccount <86586195+justgithubaccount@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:05:07 +0000 Subject: [PATCH 2/3] feat: add manual automation workspace and result inspection --- .github/workflows/ci.yaml | 1 + AGENTS.md | 2 + README.md | 36 ++- docs/automation-validation.md | 48 ++++ docs/ui-foundation.md | 25 +- package.json | 1 + src/app/automation/page.tsx | 8 + src/components/icons/index.tsx | 2 + src/components/layout/app-shell.tsx | 6 +- .../automation/automation-catalog.tsx | 51 ++++ .../automation/automation-workspace.tsx | 92 +++++++ src/features/automation/run-form.tsx | 208 ++++++++++++++ src/features/automation/run-result.tsx | 78 ++++++ tests/browser/automation.spec.ts | 256 ++++++++++++++++++ tests/browser/workspaces.spec.ts | 8 +- tests/integration/automation-proxy.mjs | 150 ++++++++++ 16 files changed, 967 insertions(+), 5 deletions(-) create mode 100644 docs/automation-validation.md create mode 100644 src/app/automation/page.tsx create mode 100644 src/features/automation/automation-catalog.tsx create mode 100644 src/features/automation/automation-workspace.tsx create mode 100644 src/features/automation/run-form.tsx create mode 100644 src/features/automation/run-result.tsx create mode 100644 tests/browser/automation.spec.ts create mode 100644 tests/integration/automation-proxy.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fc5878e..7cc7a78 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,6 +30,7 @@ jobs: - run: npm audit --audit-level=high - run: npx playwright install --with-deps chromium - run: npm run test:e2e + - run: npm run test:integration:automation - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/AGENTS.md b/AGENTS.md index d7a9fa0..25006fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ the backend limitations and the verification matrix. - `src/lib/api/`: backend schemas and one browser client. Validate JSON at this boundary. - `src/lib/async/`: shared async-state decisions. A failed request never becomes empty or zero. - `src/app/api/backend/`: fixed-upstream, allowlisted proxy; no arbitrary URLs, cookies or credential forwarding. +- `src/app/api/automation/`: separate fixed CrewAI upstream using the same transport; retain independent route/body validation. - LLM calls, storage, model/provider selection and secrets belong to FastAPI. ## Visual and interaction rules @@ -33,6 +34,7 @@ the backend limitations and the verification matrix. - Named component exports and explicit props; use `cn`, composition and primitive variants. - Backend errors use FastAPI `detail`, including 422 arrays. Validate the actual response contract. - No automatic retry of POST: the backend has no idempotency contract. +- Automation runs require fresh explicit acknowledgement after parameter edits and each submission. Timeout/navigation does not cancel upstream work. Never run real flows as an implementation smoke test. - No fake streaming, persistent-chat claims, model settings, authentication or writable behavior settings. Check the actual backend contract before adding a control. diff --git a/README.md b/README.md index b1c133f..29a7fa0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Отдельный UI для [app-poly-gitops-fastapi](https://github.com/justgithubaccount/app-poly-gitops-fastapi): проекты, быстрый чат, чат проекта, просмотр истории запросов и диагностика API. +Раздел «Автоматизация» подключает отдельный +[CrewAI backend](https://github.com/justgithubaccount/app-poly-gitops-crewai). Семантические дизайн-токены, общие компоненты, light/dark/system, адаптивная раскладка и явные состояния данных. @@ -22,6 +24,35 @@ npm run dev ограниченный `/api/backend/...` proxy. `CHAT_API_TIMEOUT_MS` по умолчанию 70000. LLM-провайдер, URL модели и ключ настраиваются в FastAPI, а не в браузере. +## Автоматизация / CrewAI + +На `/automation` доступны список flows и агентов, проверка подключения, +ручной запуск выбранного flow или серверного варианта по умолчанию. +Параметры: namespace, необязательное имя приложения и JSON-контекст. +Результат содержит итоговый ответ, шаги с агентами/задачами и длительностью, +метаданные и trace ID, если он пришёл от сервиса. + +Настройте **на сервере UI**, когда CrewAI запущен: + +```dotenv +CREWAI_API_URL=http://127.0.0.1:8001 +CREWAI_API_TIMEOUT_MS=120000 +``` + +URL не попадает в браузер; используется ограниченный `/api/automation/...` +proxy. Если CrewAI не подключён, интерфейс покажет ошибку подключения, +а проекты и чат продолжат работать независимо. + +Каждый запуск требует подтверждения: flow может обращаться к платной модели, +менять инфраструктуру или создавать issues. Подтверждение сбрасывается при +изменении параметров и после отправки. Контекст не может переопределять +`namespace` и `app_name`; секреты остаются в CrewAI, не в форме. + +API синхронный: шаги приходят после завершения. Истории запусков, живого +прогресса и отмены в нём нет. Уход со страницы или таймаут UI не отменяют +серверное выполнение; автоматического повтора POST нет. Результат на экране +не сохраняется после ухода/перезагрузки. Дедлайн proxy — от 1000 до 300000 мс. + ## Проверки ```bash @@ -30,6 +61,7 @@ npm run format:check npm run build npx playwright install chromium npm run test:e2e # браузерные тесты с явно заданными API fixtures +npm run test:integration:automation # настоящий proxy + отдельный loopback HTTP fixture ``` Browser-тесты запускают production build на 3100. Бэкенд и провайдер для них @@ -56,7 +88,7 @@ FastAPI и proxy, но подменяет только провайдера, п надёжная классификация таких ошибок требует изменения API. Настройки UI сейчас — тема и диагностика. API изменения модели/провайдера, -streaming, отдельные диалоги, auth и действия CrewAI не добавлены этим setup. +streaming, отдельные диалоги и auth не добавлены этим setup. `/health` проверяет доступность сервиса, а не провайдера модели. ## Проектные правила @@ -68,3 +100,5 @@ streaming, отдельные диалоги, auth и действия CrewAI н Репозитории backend, CrewAI, Helm, GitOps и provisioning остаются отдельными частями стека. Этот репозиторий не поднимает кластер и не меняет их настройки. +Публичность исходников не означает готовность публичного runtime: у текущих +сервисов нет пользовательской авторизации. Локальный UI слушает loopback. diff --git a/docs/automation-validation.md b/docs/automation-validation.md new file mode 100644 index 0000000..3d59e0f --- /dev/null +++ b/docs/automation-validation.md @@ -0,0 +1,48 @@ +# Automation validation — 2026-10-29 + +## Scope and evidence + +UI integration of the existing CrewAI HTTP contract. Only the UI repository +changed; no real CrewAI flow, cluster operation or paid LLM call was executed. + +| Check | Result | +| --------------------------------------------------------------- | -------------------------------------------------------------------- | +| Clean pinned `npm ci` | PASS; existing baseline 11 unit tests pass | +| `npm run check` | PASS: strict TypeScript, zero-warning ESLint, 21 unit/contract tests | +| `npm run build` | PASS: production routes including `/automation` and its proxy | +| `npm run format:check` | PASS | +| `npm audit --audit-level=high` | PASS: zero vulnerabilities | +| `PLAYWRIGHT_CHANNEL=chrome npm run test:e2e` | PASS: 19 scenarios, no retries | +| `PLAYWRIGHT_CHANNEL=chrome npm run test:integration:automation` | PASS: real Next proxy with an explicit loopback HTTP fixture | + +Tests were observed failing before implementation: the new contract modules +and the Automation workspace were missing on the baseline. + +## Covered behavior + +- Independent flows/agents/health discovery; error versus empty; stale catalog + remains visible after failed refresh; missing CrewAI does not break projects. +- Selected-flow and server-default execution; namespace/app name/JSON context; + reserved target overrides and malformed/non-object JSON rejected. +- Acknowledgement is mandatory, resets after input changes/submission and does + not automatically re-arm after failures. Pending prevents duplicate submits. +- Returned final answer, step output, agents/tasks, duration and trace metadata. + No fake live steps, run persistence or cancellation claim. +- Timeout preserves form parameters, warns that execution may continue and + never automatically repeats POST. +- Real Next route forwards only validated body to the fixed destination, does + not forward browser cookies, and rejects cross-origin/target-override writes. +- Desktop/mobile screenshots were opened and inspected at 1440px and 390px. + Long names, output and metadata fit at 320/390/1440px; navigation wraps. + +Local browser verification uses installed Chrome because the Browser plugin is +not available. CI installs Playwright Chromium. Fixture browser evidence proves +the UI contract, while the separate loopback check additionally proves the actual +proxy hop; neither proves real CrewAI execution or cluster/LLM readiness. + +## Preview and runtime boundary + +The local preview can show the new workspace with CrewAI unconfigured. It must +show a real connection error and disabled execution, never substitute fixture +data. To connect an actual service, set server-only `CREWAI_API_URL` and configure +that service's keys/tools/access independently. No cloud deployment is included. diff --git a/docs/ui-foundation.md b/docs/ui-foundation.md index 484b445..3651714 100644 --- a/docs/ui-foundation.md +++ b/docs/ui-foundation.md @@ -79,11 +79,29 @@ initially expose only local appearance preferences and API diagnostics. Provider/model selection and credentials remain on FastAPI. This UI never collects or persists the optional `userApiKey` field. +## CrewAI contract + +Verified `app-poly-gitops-crewai@0835d3102555e6cef4fb700c56969ecddc6f6ddd`: +`GET /health`, `/flows`, `/agents`; `POST /run` and `/run/{flow_name}`. +The `/automation` workspace covers these endpoints through `/api/automation`. +Discovery/health use independent SWR keys and local read states. +Runs accept `namespace`, optional `app_name` and optional object `context`. +The UI/proxy reject context keys that override the visible target fields. + +Results contain `final_answer`, `steps[{agent,task,output,duration_ms}]` and +`metadata`, optionally including `total_duration_ms` and `trace_id`. Outputs +are rendered as plain text, not executable HTML. The API returns steps only +after completion; it has no run listing, persistence, progress or cancellation. +Results remain only in the mounted UI, not a fabricated history. +Manual acknowledgement is mandatory for each submission and is invalidated +when form inputs change. Backend mutation policy and credentials remain in +CrewAI; the UI cannot claim that an arbitrary flow is read-only. + The source repository can be public; runtime access is a separate concern. The current backend has no authentication/ownership boundary. Local startup binds only to loopback. A public multi-user deployment requires an explicit -backend access contract. CrewAI is a separate service and needs a verified API -contract before any corresponding UI controls are introduced. +backend access contract. CrewAI remains a separate service with the verified +contract above; adding UI controls does not grant cluster access. ## Code and environment setup @@ -99,11 +117,14 @@ src/components/layout/ responsive application shell src/features/projects/ project list/create src/features/chat/ composer, messages, request-history inspection src/features/settings/ appearance and diagnostics +src/features/automation/ service catalog, manual runs and result inspection src/lib/api/ schemas, client and proxy helpers src/lib/async/ shared read-state resolver ``` `CHAT_API_URL` and `CHAT_API_TIMEOUT_MS` are server-only runtime variables. +CrewAI uses separate server-only `CREWAI_API_URL` (unset by default) and +`CREWAI_API_TIMEOUT_MS` (120000ms, allowed 1000–300000ms). The browser uses `/api/backend/...`; the route has a fixed configured upstream and a method/path allowlist. It never accepts arbitrary upstream URLs, forwards browser credentials or follows redirects. FastAPI uses `detail` for diff --git a/package.json b/package.json index 772e005..1ced2b5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:integration:automation": "node tests/integration/automation-proxy.mjs", "check": "npm run typecheck && npm run lint && npm test", "format:check": "prettier --check .", "format": "prettier --write ." diff --git a/src/app/automation/page.tsx b/src/app/automation/page.tsx new file mode 100644 index 0000000..87586ad --- /dev/null +++ b/src/app/automation/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { AutomationWorkspace } from "@/features/automation/automation-workspace"; + +export const metadata: Metadata = { title: "Автоматизация" }; + +export default function AutomationPage() { + return ; +} diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 8ac856b..ba33b5b 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -12,6 +12,7 @@ import { Send, Settings, Sun, + Workflow, type LucideProps, } from "lucide-react"; @@ -29,6 +30,7 @@ const icons = { send: Send, settings: Settings, light: Sun, + automation: Workflow, } as const; export type IconName = keyof typeof icons; diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index b80226b..aec9ffb 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"; const navigation: { href: string; label: string; icon: IconName }[] = [ { href: "/", label: "Проекты", icon: "project" }, { href: "/chat", label: "Быстрый чат", icon: "chat" }, + { href: "/automation", label: "Автоматизация", icon: "automation" }, { href: "/settings", label: "Настройки", icon: "settings" }, ]; @@ -29,7 +30,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { > Chat API -