From 6ab3835de45f8db9a3774c757979536808cb3613 Mon Sep 17 00:00:00 2001 From: ibolton336 Date: Wed, 29 Jul 2026 08:20:37 -0400 Subject: [PATCH] :sparkles: Import the reference client stack (client core, hub-shim, UI, harnesses, demo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports the working client stack prototyped in ibolton336/agentcontroller-client — the running system behind the contract proposal on #22 — cleaned of POC scaffolding, as clients/, plus ADRs 0004 (verified client contract and transports) and 0005 (platform-resolved params). Each piece is the reference implementation for a planned stream: clients/packages/agentic-client isomorphic core: contract types, AcpSession, HTTP transport (streams 3, #22-#24) clients/packages/hub-shim proposed Hub passthrough surface: run CRUD, ACP proxy, catalog read/write routes, image catalog and defaults (stream 2, #21) clients/packages/agentrun-client node-side client shaped for konveyor/editor-extensions clients/ui PatternFly SPA: streaming chat with HITL permission round-trips, run launcher, playbook stage ladder, management console clients/harness-goose working reference for the KONVEYOR_* env contract (stream 4, #25/#26) clients/harness-mock deterministic ACP agent for e2e fixtures clients/deploy interim in-cluster gateway + UI manifests Verified end to end against the live controller on minikube (Agent Sandbox v0.5.0) with both the deterministic mock agent and a real goose+Bedrock agent, across both run kinds: AgentRun and AgentPlaybookRun (assess -> remediate -> validate sharing one target branch). The client contract tracks the #53 migration-harness env/param shape; model selection is explicit and validated against each Agent's declared providers. Squashed from the branch's incremental history into the single import commit this PR was always meant to land as. Signed-off-by: ibolton336 Co-Authored-By: Claude Fable 5 --- README.md | 4 + .../unreleased/clients-reference-stack.yaml | 14 + clients/.gitignore | 5 + clients/README.md | 69 + clients/deploy/README.md | 74 + clients/deploy/gateway/Dockerfile | 28 + clients/deploy/manifests/gateway.yaml | 113 + clients/deploy/manifests/ingress.example.yaml | 29 + clients/deploy/manifests/kustomization.yaml | 16 + clients/deploy/manifests/ui.yaml | 55 + clients/deploy/ui/Dockerfile | 18 + clients/deploy/ui/nginx.conf | 31 + clients/docs/DEMO.md | 181 ++ clients/docs/demo/real-run.yaml | 19 + clients/docs/demo/skill-probe.yaml | 41 + clients/hack/demo-check.sh | 51 + clients/hack/demo-down.sh | 27 + clients/hack/demo-up.sh | 152 ++ clients/harness-goose/Dockerfile | 51 + clients/harness-goose/entrypoint.sh | 91 + clients/harness-mock/Dockerfile | 7 + clients/harness-mock/package-lock.json | 56 + clients/harness-mock/package.json | 10 + clients/harness-mock/server.mjs | 250 ++ clients/manifests/goose-bedrock.yaml | 94 + clients/manifests/image-catalog.yaml | 55 + clients/manifests/samples.yaml | 118 + .../packages/agentic-client/dev/selfcheck.ts | 228 ++ .../packages/agentic-client/package-lock.json | 548 +++++ clients/packages/agentic-client/package.json | 39 + .../packages/agentic-client/src/acp/index.ts | 625 +++++ .../agentic-client/src/contract/index.ts | 829 +++++++ clients/packages/agentic-client/src/index.ts | 12 + .../src/transport-shim/index.ts | 283 +++ clients/packages/agentic-client/tsconfig.json | 16 + clients/packages/agentrun-client/dev/demo.ts | 92 + .../agentrun-client/dev/local-smoke.ts | 70 + .../agentrun-client/package-lock.json | 1401 +++++++++++ clients/packages/agentrun-client/package.json | 27 + clients/packages/agentrun-client/src/acp.ts | 110 + clients/packages/agentrun-client/src/index.ts | 12 + clients/packages/agentrun-client/src/kube.ts | 220 ++ .../agentrun-client/src/portforward.ts | 82 + clients/packages/agentrun-client/src/types.ts | 300 +++ .../packages/agentrun-client/tsconfig.json | 15 + .../packages/hub-shim/dev/browser-smoke.ts | 353 +++ clients/packages/hub-shim/dev/dial-check.ts | 126 + clients/packages/hub-shim/dev/drop-check.ts | 224 ++ clients/packages/hub-shim/package-lock.json | 1380 +++++++++++ clients/packages/hub-shim/package.json | 24 + clients/packages/hub-shim/src/acp-dial.ts | 107 + clients/packages/hub-shim/src/defaults.ts | 257 ++ clients/packages/hub-shim/src/server.ts | 1665 +++++++++++++ clients/packages/hub-shim/tsconfig.json | 13 + clients/ui/.gitignore | 3 + clients/ui/README.md | 12 + clients/ui/index.html | 12 + clients/ui/package-lock.json | 2061 +++++++++++++++++ clients/ui/package.json | 27 + clients/ui/src/App.tsx | 237 ++ clients/ui/src/app.css | 241 ++ .../ui/src/components/AgentDesignerModal.tsx | 705 ++++++ clients/ui/src/components/AgentsPage.tsx | 250 ++ clients/ui/src/components/BranchPanel.tsx | 272 +++ clients/ui/src/components/ChatPanel.tsx | 577 +++++ .../src/components/CreatePlaybookRunModal.tsx | 530 +++++ clients/ui/src/components/CreateRunModal.tsx | 391 ++++ .../ui/src/components/LoadDefaultsButton.tsx | 71 + clients/ui/src/components/PhaseLabel.tsx | 16 + .../src/components/PlaybookComposerModal.tsx | 501 ++++ .../src/components/PlaybookRunDetailPage.tsx | 474 ++++ .../ui/src/components/PlaybookRunsPage.tsx | 253 ++ clients/ui/src/components/PlaybooksPage.tsx | 235 ++ clients/ui/src/components/RunDetailPage.tsx | 431 ++++ clients/ui/src/components/RunsPage.tsx | 254 ++ clients/ui/src/components/SkillCardModal.tsx | 270 +++ .../src/components/SkillCollectionModal.tsx | 407 ++++ clients/ui/src/components/SkillsPage.tsx | 454 ++++ clients/ui/src/components/sources.tsx | 481 ++++ clients/ui/src/format.ts | 80 + clients/ui/src/main.tsx | 9 + clients/ui/src/vite-env.d.ts | 10 + clients/ui/tsconfig.app.json | 24 + clients/ui/tsconfig.json | 7 + clients/ui/tsconfig.node.json | 17 + clients/ui/vite.config.ts | 21 + .../0004-client-contract-and-transports.md | 150 ++ docs/adr/0005-platform-resolved-params.md | 141 ++ 88 files changed, 20341 insertions(+) create mode 100644 changes/unreleased/clients-reference-stack.yaml create mode 100644 clients/.gitignore create mode 100644 clients/README.md create mode 100644 clients/deploy/README.md create mode 100644 clients/deploy/gateway/Dockerfile create mode 100644 clients/deploy/manifests/gateway.yaml create mode 100644 clients/deploy/manifests/ingress.example.yaml create mode 100644 clients/deploy/manifests/kustomization.yaml create mode 100644 clients/deploy/manifests/ui.yaml create mode 100644 clients/deploy/ui/Dockerfile create mode 100644 clients/deploy/ui/nginx.conf create mode 100644 clients/docs/DEMO.md create mode 100644 clients/docs/demo/real-run.yaml create mode 100644 clients/docs/demo/skill-probe.yaml create mode 100755 clients/hack/demo-check.sh create mode 100755 clients/hack/demo-down.sh create mode 100755 clients/hack/demo-up.sh create mode 100644 clients/harness-goose/Dockerfile create mode 100644 clients/harness-goose/entrypoint.sh create mode 100644 clients/harness-mock/Dockerfile create mode 100644 clients/harness-mock/package-lock.json create mode 100644 clients/harness-mock/package.json create mode 100644 clients/harness-mock/server.mjs create mode 100644 clients/manifests/goose-bedrock.yaml create mode 100644 clients/manifests/image-catalog.yaml create mode 100644 clients/manifests/samples.yaml create mode 100644 clients/packages/agentic-client/dev/selfcheck.ts create mode 100644 clients/packages/agentic-client/package-lock.json create mode 100644 clients/packages/agentic-client/package.json create mode 100644 clients/packages/agentic-client/src/acp/index.ts create mode 100644 clients/packages/agentic-client/src/contract/index.ts create mode 100644 clients/packages/agentic-client/src/index.ts create mode 100644 clients/packages/agentic-client/src/transport-shim/index.ts create mode 100644 clients/packages/agentic-client/tsconfig.json create mode 100644 clients/packages/agentrun-client/dev/demo.ts create mode 100644 clients/packages/agentrun-client/dev/local-smoke.ts create mode 100644 clients/packages/agentrun-client/package-lock.json create mode 100644 clients/packages/agentrun-client/package.json create mode 100644 clients/packages/agentrun-client/src/acp.ts create mode 100644 clients/packages/agentrun-client/src/index.ts create mode 100644 clients/packages/agentrun-client/src/kube.ts create mode 100644 clients/packages/agentrun-client/src/portforward.ts create mode 100644 clients/packages/agentrun-client/src/types.ts create mode 100644 clients/packages/agentrun-client/tsconfig.json create mode 100644 clients/packages/hub-shim/dev/browser-smoke.ts create mode 100644 clients/packages/hub-shim/dev/dial-check.ts create mode 100644 clients/packages/hub-shim/dev/drop-check.ts create mode 100644 clients/packages/hub-shim/package-lock.json create mode 100644 clients/packages/hub-shim/package.json create mode 100644 clients/packages/hub-shim/src/acp-dial.ts create mode 100644 clients/packages/hub-shim/src/defaults.ts create mode 100644 clients/packages/hub-shim/src/server.ts create mode 100644 clients/packages/hub-shim/tsconfig.json create mode 100644 clients/ui/.gitignore create mode 100644 clients/ui/README.md create mode 100644 clients/ui/index.html create mode 100644 clients/ui/package-lock.json create mode 100644 clients/ui/package.json create mode 100644 clients/ui/src/App.tsx create mode 100644 clients/ui/src/app.css create mode 100644 clients/ui/src/components/AgentDesignerModal.tsx create mode 100644 clients/ui/src/components/AgentsPage.tsx create mode 100644 clients/ui/src/components/BranchPanel.tsx create mode 100644 clients/ui/src/components/ChatPanel.tsx create mode 100644 clients/ui/src/components/CreatePlaybookRunModal.tsx create mode 100644 clients/ui/src/components/CreateRunModal.tsx create mode 100644 clients/ui/src/components/LoadDefaultsButton.tsx create mode 100644 clients/ui/src/components/PhaseLabel.tsx create mode 100644 clients/ui/src/components/PlaybookComposerModal.tsx create mode 100644 clients/ui/src/components/PlaybookRunDetailPage.tsx create mode 100644 clients/ui/src/components/PlaybookRunsPage.tsx create mode 100644 clients/ui/src/components/PlaybooksPage.tsx create mode 100644 clients/ui/src/components/RunDetailPage.tsx create mode 100644 clients/ui/src/components/RunsPage.tsx create mode 100644 clients/ui/src/components/SkillCardModal.tsx create mode 100644 clients/ui/src/components/SkillCollectionModal.tsx create mode 100644 clients/ui/src/components/SkillsPage.tsx create mode 100644 clients/ui/src/components/sources.tsx create mode 100644 clients/ui/src/format.ts create mode 100644 clients/ui/src/main.tsx create mode 100644 clients/ui/src/vite-env.d.ts create mode 100644 clients/ui/tsconfig.app.json create mode 100644 clients/ui/tsconfig.json create mode 100644 clients/ui/tsconfig.node.json create mode 100644 clients/ui/vite.config.ts create mode 100644 docs/adr/0004-client-contract-and-transports.md create mode 100644 docs/adr/0005-platform-resolved-params.md diff --git a/README.md b/README.md index 155397c4..4f76b895 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ agentic-controller/ internal/controller/ Controller implementations internal/registry/ OCI registry client docs/adr/ Architecture Decision Records + harness/ Migration harness (Go) baked into the base image + images/ Agent container images (base, runtime, language) + clients/ Reference client stack (client core, hub-shim, + browser UI, agent harnesses, demo) — see clients/README.md skills/ Agent skills for contributors CONTEXT.md Domain glossary AGENTS.md Agent-facing instructions diff --git a/changes/unreleased/clients-reference-stack.yaml b/changes/unreleased/clients-reference-stack.yaml new file mode 100644 index 00000000..215935ac --- /dev/null +++ b/changes/unreleased/clients-reference-stack.yaml @@ -0,0 +1,14 @@ +kind: feature + +description: > + Import the reference client stack under clients/ — isomorphic client + core, hub-shim (the proposed Hub passthrough proxy surface), PatternFly + browser UI, mock and goose agent harnesses, in-cluster deploy manifests, + and the end-to-end demo — plus ADRs 0004 (verified client contract and + transports) and 0005 (platform-resolved params). The stack covers both + run kinds (AgentRun and AgentPlaybookRun with a stage ladder), a + management console for the catalog kinds (Agents, SkillCards, + SkillCollections, AgentPlaybooks) backed by shim write routes, an image + catalog with defaults, explicit model selection validated against + declared providers, and the env/param contract of the #53 + migration-harness. diff --git a/clients/.gitignore b/clients/.gitignore new file mode 100644 index 00000000..51b1beeb --- /dev/null +++ b/clients/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ + +.claude/settings.local.json +.DS_Store diff --git a/clients/README.md b/clients/README.md new file mode 100644 index 00000000..55774d2f --- /dev/null +++ b/clients/README.md @@ -0,0 +1,69 @@ +# clients/ — reference client stack + +Working client-side implementation of this repository's AgentRun/ACP +contract, developed against the live controller on minikube and verified +end-to-end with both a deterministic mock agent and a real goose+Bedrock +agent. It exists so the Hub, UI, and base-image streams can build against +running code instead of a whiteboard: + +- the **hub-shim** HTTP surface is the proposed contract for the Hub + passthrough proxy (stream 2, issue #21) — see ADR 0004 +- the **UI** and **client core** are the reference for the tackle2-ui + client layer (stream 3, issues #22–#24) +- the **goose harness** is a working reference for the interactive + (ACP-serving) side of the base image (stream 4, issues #25/#26). The + batch-mode harness landed separately as the repo-root `harness/` Go + module + `images/agent-base-goose-java` (#33); both consume the same + `KONVEYOR_MODEL_*` env contract. + +## Layout + +| Path | What | +|------|------| +| `packages/agentic-client/` | Isomorphic (browser + node) client core: CRD contract types + helpers, `AcpSession` over WebSocket (JSON-RPC 2.0), hub-shim HTTP transport. Zero runtime dependencies. | +| `packages/agentrun-client/` | Node-side client: create/watch AgentRuns against the apiserver, resolve the ACP endpoint (`status.sandboxName` → pod, `status.secretKeyRef` → key), port-forward tunnel. Shaped for konveyor/editor-extensions. | +| `packages/hub-shim/` | Localhost gateway serving the SHIM HTTP API v1 (REST + WS `/acp` proxy with server-side `X-Secret-Key` injection). Stands in for the future Hub passthrough proxy. | +| `ui/` | Browser SPA (PatternFly): run list, create form with platform-resolved params (ADR 0005), streaming chat with permission (HITL) round-trips. | +| `harness-mock/` | Deterministic ACP agent (no LLM): real protocol via the ACP SDK's server side, scripted behaviors (`TEST_PERMISSION`, `TEST_CANCEL`, `TEST_DROP`). Test fixture and demo agent. | +| `harness-goose/` | Real agent base: goose `serve` on `:4000` behind an entrypoint that adapts the controller's `KONVEYOR_*` env contract (clones the `repository` param, maps model env, writes prompt hints). | +| `deploy/` | In-cluster deployment of gateway + UI (interim, pre-Hub). | +| `manifests/` | Sample Agent/LLMProvider CRs: `samples.yaml` (mock) and `goose-bedrock.yaml` (real goose on AWS Bedrock). | +| `hack/` | `demo-up.sh` / `demo-check.sh` / `demo-down.sh` — converge the cluster, smoke the full ACP round-trip, tear down. | +| `docs/DEMO.md` | Narrated end-to-end demo runbook (browser + IDE + real agent). | + +Architecture write-ups live with the repo's ADRs: `docs/adr/0004` +(verified client contract and layered transports) and `docs/adr/0005` +(platform-resolved params). + +## Quickstart + +Prereqs: minikube running, Agent Sandbox v0.5.0 installed, the controller +deployed from the repo root (`make docker-build deploy IMG=agentic-controller:dev`), +Node ≥ 22.9. + +```sh +cd clients +hack/demo-up.sh # converge cluster + start shim & UI, prints URLs +hack/demo-check.sh # smoke: full ACP round-trip on a throwaway mock run +hack/demo-down.sh # stop local processes; cluster untouched +``` + +Then follow `docs/DEMO.md`. + +## Status: interim by design + +The controller and CRDs are the real ones from this repository. The rest +is scaffolding with a named replacement: + +- `hub-shim` → the real Hub passthrough proxy (its route table is the + handover spec) +- `ui` → absorbed into tackle2-ui +- `harness-goose` → the stream-4 base image (the batch side already exists: + repo-root `harness/` + `images/agent-base-goose-java`, #33; this one + covers the interactive `goose serve` / ACP mode until they converge) +- `harness-mock` → stays, as a deterministic test fixture + +Every piece encodes contract facts verified against the live controller +(pod name == `status.sandboxName`, ACP Secret data key `secret-key`, +headless portless Service, whole-spec immutability) — see ADR 0004 for +the full list and rationale. diff --git a/clients/deploy/README.md b/clients/deploy/README.md new file mode 100644 index 00000000..316ab869 --- /dev/null +++ b/clients/deploy/README.md @@ -0,0 +1,74 @@ +# In-cluster deployment (interim, pre-Hub) + +Runs the browser UI + gateway **on the cluster** — no laptop processes. +Both components are interim by design: the gateway seat is replaced by the +real Konveyor Hub passthrough proxy later, and the UI page is absorbed +into tackle2-ui (see `docs/adr/0004-client-contract-and-transports.md`). + +``` +browser ── ingress/route (TLS + SSO) ── agentic-ui (nginx, static SPA) + │ /api + WS, same-origin + agentic-gateway (SA + RBAC) + │ CRs via k8s API · pod :4000 via service DNS + agentic-controller / Agent Sandbox / sandbox pods +``` + +Differences from laptop mode: the gateway **direct-dials** +`..svc:4000` (no port-forwarding; auto-detected +in-cluster, `ACP_DIAL` overrides) and authenticates as a namespace-scoped +ServiceAccount instead of your kubeconfig. + +## Build the images (context = repo root) + +```sh +# local cluster (minikube): +minikube image build -t agentic-gateway:dev -f deploy/gateway/Dockerfile . +minikube image build -t agentic-ui:dev -f deploy/ui/Dockerfile . + +# real cluster: docker build + push, then kustomize-patch the image refs. +``` + +## Deploy + +```sh +kubectl apply -k deploy/manifests +kubectl -n konveyor-agents rollout status deploy/agentic-gateway deploy/agentic-ui +``` + +Prereqs already on the cluster: the agentic-controller (deployed from +the repo root: `make deploy`), Agent Sandbox, and the sample +Agents/LLMProviders (`hack/demo-up.sh` converges all of that). + +## Access + +With an ingress (see `ingress.example.yaml`) — or without one: + +```sh +kubectl -n konveyor-agents port-forward svc/agentic-ui 8081:80 +open http://localhost:8081 +``` + +Everything (REST + WebSocket chat) flows through that single origin. + +## Verify + +The browser-constraint smoke works through the deployed stack end to end +(nginx → gateway → controller → sandbox ACP): + +```sh +cd packages/hub-shim +SHIM_URL=http://localhost:8081 npx tsx dev/browser-smoke.ts +``` + +## Teardown + +```sh +kubectl delete -k deploy/manifests +``` + +## Auth (deliberate gap) + +The gateway has no auth of its own — protect it at the ingress (oauth2-proxy, +or an OpenShift Route with an oauth-proxy sidecar). Per-user identity and +RBAC on runs is precisely the value the real Hub proxy adds; bolting a full +auth stack onto this interim component would be building Hub twice. diff --git a/clients/deploy/gateway/Dockerfile b/clients/deploy/gateway/Dockerfile new file mode 100644 index 00000000..e9853276 --- /dev/null +++ b/clients/deploy/gateway/Dockerfile @@ -0,0 +1,28 @@ +# agentic-gateway: the hub-shim packaged for in-cluster use. +# Build context is the REPO ROOT (the shim imports ../agentrun-client/src): +# docker build -t agentic-gateway:dev -f deploy/gateway/Dockerfile . +# minikube image build -t agentic-gateway:dev -f deploy/gateway/Dockerfile . +FROM node:22-slim + +WORKDIR /app + +# Install each package's deps (tsx runs the TypeScript sources directly, +# matching the repo's dev style; dev deps are required for that). +COPY packages/agentrun-client/package*.json packages/agentrun-client/ +COPY packages/hub-shim/package*.json packages/hub-shim/ +RUN cd packages/agentrun-client && npm ci --no-audit --no-fund \ + && cd ../hub-shim && npm ci --no-audit --no-fund + +COPY packages/agentrun-client packages/agentrun-client +COPY packages/hub-shim packages/hub-shim + +# In-cluster defaults: bind all interfaces; ACP dial mode auto-detects +# "direct" from the serviceaccount env (no port-forwarding in-cluster). +ENV HOST=0.0.0.0 \ + PORT=7080 \ + NAMESPACE=konveyor-agents + +USER node +EXPOSE 7080 +WORKDIR /app/packages/hub-shim +CMD ["npx", "tsx", "src/server.ts"] diff --git a/clients/deploy/manifests/gateway.yaml b/clients/deploy/manifests/gateway.yaml new file mode 100644 index 00000000..69153d7b --- /dev/null +++ b/clients/deploy/manifests/gateway.yaml @@ -0,0 +1,113 @@ +# agentic-gateway: in-cluster stand-in for the future Hub passthrough proxy. +# Namespace-scoped RBAC — exactly the surface the SHIM API v1 needs. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: agentic-gateway + namespace: konveyor-agents +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: agentic-gateway + namespace: konveyor-agents +rules: + # Run kinds: create/delete but never update (specs are immutable). + - apiGroups: ["konveyor.io"] + resources: ["agentruns", "agentplaybookruns"] + verbs: ["get", "list", "watch", "create", "delete"] + # Catalog kinds the management UI authors (POST/PUT/DELETE routes). + - apiGroups: ["konveyor.io"] + resources: ["agents", "skillcards", "skillcollections", "agentplaybooks"] + verbs: ["get", "list", "create", "update", "delete"] + # Providers are cluster-admin-owned — the API surface is read-only. + # "create" exists ONLY for POST /api/defaults, which seeds the default + # LLMProvider (create-only; existing providers are never touched). + - apiGroups: ["konveyor.io"] + resources: ["llmproviders"] + verbs: ["get", "list", "create"] + # get: ACP key Secrets. create/update: the hub-token Secret upsert the + # defaults seeding performs (ensureHubTokenSecret). + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "update"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + # Agent-image catalog ConfigMap (GET /api/images, seeded by /api/defaults). + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: agentic-gateway + namespace: konveyor-agents +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: agentic-gateway +subjects: + - kind: ServiceAccount + name: agentic-gateway + namespace: konveyor-agents +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agentic-gateway + namespace: konveyor-agents + labels: + app.kubernetes.io/name: agentic-gateway +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: agentic-gateway + template: + metadata: + labels: + app.kubernetes.io/name: agentic-gateway + spec: + serviceAccountName: agentic-gateway + containers: + - name: gateway + image: agentic-gateway:dev + imagePullPolicy: IfNotPresent + ports: + - containerPort: 7080 + name: http + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: agentic-gateway + namespace: konveyor-agents + labels: + app.kubernetes.io/name: agentic-gateway +spec: + selector: + app.kubernetes.io/name: agentic-gateway + ports: + - name: http + port: 7080 + targetPort: http diff --git a/clients/deploy/manifests/ingress.example.yaml b/clients/deploy/manifests/ingress.example.yaml new file mode 100644 index 00000000..39dac41a --- /dev/null +++ b/clients/deploy/manifests/ingress.example.yaml @@ -0,0 +1,29 @@ +# EXAMPLE — not applied by the kustomization. Adapt host/class/TLS to your +# environment and put SSO in front (oauth2-proxy annotations, or on +# OpenShift use a Route with an oauth-proxy sidecar instead). +# +# Only the UI needs exposing: nginx inside agentic-ui proxies /api (HTTP + +# WebSocket) to the gateway Service, so one host serves everything +# same-origin. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: agentic-ui + namespace: konveyor-agents + annotations: + # WebSocket sessions are long-lived: + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +spec: + ingressClassName: nginx + rules: + - host: agentic.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: agentic-ui + port: + number: 80 diff --git a/clients/deploy/manifests/kustomization.yaml b/clients/deploy/manifests/kustomization.yaml new file mode 100644 index 00000000..92aa9acb --- /dev/null +++ b/clients/deploy/manifests/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# In-cluster deployment of the interim agentic UI + gateway. +# Prereqs: the agentic-controller and Agent Sandbox are already installed +# (see manifests/controller/install.yaml), and the agentic-gateway:dev / +# agentic-ui:dev images are loadable by the cluster (minikube image build, +# or retag + push to a registry and patch the images below). +resources: + - gateway.yaml + - ui.yaml + +# Ingress is environment-specific — see ingress.example.yaml. Put TLS and +# SSO (oauth2-proxy / OpenShift Route auth) in front: the gateway itself +# is deliberately auth-less, exactly like the future Hub proxy seat it +# stands in for is not. diff --git a/clients/deploy/manifests/ui.yaml b/clients/deploy/manifests/ui.yaml new file mode 100644 index 00000000..dca4ff0f --- /dev/null +++ b/clients/deploy/manifests/ui.yaml @@ -0,0 +1,55 @@ +# agentic-ui: static SPA behind unprivileged nginx; nginx proxies /api +# (HTTP + WebSocket) to the agentic-gateway Service (same-origin for the +# browser). +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agentic-ui + namespace: konveyor-agents + labels: + app.kubernetes.io/name: agentic-ui +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: agentic-ui + template: + metadata: + labels: + app.kubernetes.io/name: agentic-ui + spec: + containers: + - name: ui + image: agentic-ui:dev + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: agentic-ui + namespace: konveyor-agents + labels: + app.kubernetes.io/name: agentic-ui +spec: + selector: + app.kubernetes.io/name: agentic-ui + ports: + - name: http + port: 80 + targetPort: http diff --git a/clients/deploy/ui/Dockerfile b/clients/deploy/ui/Dockerfile new file mode 100644 index 00000000..35946123 --- /dev/null +++ b/clients/deploy/ui/Dockerfile @@ -0,0 +1,18 @@ +# agentic-ui: static build of the SPA served by unprivileged nginx, which +# also proxies /api (HTTP + WebSocket) to the agentic-gateway Service so +# the browser talks same-origin. +# Build context is the REPO ROOT (the ui imports ../packages/agentic-client/src): +# docker build -t agentic-ui:dev -f deploy/ui/Dockerfile . +# minikube image build -t agentic-ui:dev -f deploy/ui/Dockerfile . +FROM node:22-slim AS build +WORKDIR /src +COPY packages/agentic-client packages/agentic-client +COPY ui ui +# VITE_SHIM_URL deliberately unset: the production bundle falls back to +# window.location.origin (same-origin; nginx owns the /api proxying). +RUN cd ui && npm ci --no-audit --no-fund && npm run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine +COPY deploy/ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/ui/dist /usr/share/nginx/html +EXPOSE 8080 diff --git a/clients/deploy/ui/nginx.conf b/clients/deploy/ui/nginx.conf new file mode 100644 index 00000000..53cdec9c --- /dev/null +++ b/clients/deploy/ui/nginx.conf @@ -0,0 +1,31 @@ +# Serves the static SPA and proxies /api (HTTP + WebSocket) to the +# agentic-gateway Service, so the browser only ever talks same-origin. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 8080; + + # REST + WebSocket to the gateway (ACP sessions are long-lived). + location /api/ { + proxy_pass http://agentic-gateway:7080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location = /healthz { + proxy_pass http://agentic-gateway:7080/healthz; + } + + # Static SPA (no client-side routes today; try_files future-proofs). + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } +} diff --git a/clients/docs/DEMO.md b/clients/docs/DEMO.md new file mode 100644 index 00000000..d56be394 --- /dev/null +++ b/clients/docs/DEMO.md @@ -0,0 +1,181 @@ +# Demo: the agentic platform, end to end (~12 minutes) + +What this shows: **this repository's controller doing real work.** A +browser UI and the VSCode extension drive the same AgentRun/ACP contract, +a real goose+Bedrock agent reads a real repository, and a run started in +the web UI is picked up from the IDE — the architect → developer handoff. + +No simulator anywhere: the reconciler is the real controller, the sandbox +is Agent Sandbox v0.5.0, the agent is goose 1.39 on AWS Bedrock. + +> **Contract update (2026-07-28, PR #53 alignment).** The platform-resolved +> params flow this demo narrates (application picker → repository/branch +> resolved via ADR 0005 annotations, identity-Secret bridge) is RETIRED. +> The shim now injects `HUB_BASE_URL`/`HUB_TOKEN`/`APP_ID`/`TARGET_BRANCH` +> as `spec.env`, and the Konveyor-aware migration-harness pulls the repo, +> decrypted git identity, and analysis from the Hub itself — withholding +> credentials from the agent. `goose-harness:dev` (clone-from-param) is +> deprecated in favor of the #53 image hierarchy (`agent-base`/`agent-java` +> …). The mock create-flow beat still works (params are plain form fields +> now); the real-agent beats need the #53 images. The demo script below +> predates this and is refreshed as part of the seeded-defaults phase. + +> **Seeded defaults + image catalog (Phase 2, 2026-07-28).** Seeding is +> now API-first: the UI's **Load defaults** button (or +> `curl -X POST :7080/api/defaults`) creates the managed default set — +> LLMProvider `gcp-vertex-ai`, the plan/execute/verify stage SkillCards, +> the `javaee-to-quarkus` and `patternfly-migration` domain cards, three +> `migration-*-agent` stage Agents on `agent-java` plus three +> `patternfly-*-agent` stage Agents on `agent-nodejs`, both playbooks, and +> the `agent-image-catalog` ConfigMap. Create-only and re-runnable; every +> resource carries `konveyor.io/managed=true`. `GET /api/images` serves +> the catalog (ConfigMap first, built-in hierarchy as fallback) and the +> run launcher annotates each agent's image from it. `demo-up.sh` still +> applies `samples.yaml` (mock fixture) and the catalog ConfigMap, but no +> longer needs to own domain resources. + +All paths below are relative to `clients/` in this repository. + +## The stack + +| Piece | What | Where | +|---|---|---| +| minikube | konveyor CRDs, Agent Sandbox v0.5.0, **agentic-controller** | this repo: `make docker-build deploy IMG=agentic-controller:dev` | +| `packages/hub-shim` | stand-in for the future Hub passthrough proxy (REST + WS `/acp`, issue #21) | :7080 | +| `ui/` | browser SPA (PatternFly) | :5199 | +| extension | `editor-extensions-cluster-agent` branch `feature/cluster-agent` | F5 dev host | +| `goose-harness:dev` | real agent base: entrypoint clones the repo, maps model env, runs `goose serve` | built into minikube (auto-rebuilt if missing) | +| `acp-mock-harness:dev` | deterministic mock agent (no LLM) — for the create-flow beat | built into minikube (auto-rebuilt if missing) | + +**One command** (idempotent; safe after reboot / `minikube stop`): + +```sh +hack/demo-up.sh # preflight + converge cluster + start shim & UI, prints URLs +hack/demo-check.sh # pre-demo smoke: full ACP round-trip on a throwaway mock run +hack/demo-down.sh # stop the local processes; cluster untouched +``` + +Run `demo-check.sh` right before presenting — exit 0 means every beat's +surface is live (it creates, chats with, replays, and deletes a mock run +through the same shim/WS path the browser uses; costs nothing). + +It verifies the controller deployment, rebuilds missing harness images, +applies `manifests/samples.yaml` (+ `goose-bedrock.yaml` when +`aws-bedrock-creds` exists), and gates on the Agent going Ready. Only true +prerequisites: minikube running, Agent Sandbox installed, and the +controller deployed from this repo (see the stack table). Sandbox pods run +with `restartPolicy: OnFailure` (since #33), but don't rely on old runs +after a minikube restart — create fresh ones right before presenting. + +## Beat 1 — create a run in the browser (2 min) + +Open http://localhost:5199 → **Create run** → agent `migration-analyzer` +(the mock — instant, free) → application **Coolstore** → Create. + +Note there is no repository field to type into: the agent declares that its +`repository`, `branch`, and git credentials come from the application +(ADR 0005), so the form shows what the platform will resolve and asks only +for what a human should actually answer. Worth saying out loud — it is the +whole point of the beat. + +Narrate what's happening live: the SPA POSTs to the shim, the shim creates +the AgentRun CR, the **real controller** validates the Agent/provider chain +and creates a Sandbox CR, Agent Sandbox spins the pod, the phase label +flips Pending → Running, and the chat auto-connects over WebSocket through +the shim (X-Secret-Key injected server-side — a browser can't set WS +headers, which is exactly why the Hub proxy seat exists). + +Send a message; show streamed chunks + the tool-call card. Delete the run +from the kebab (cascade: Sandbox, pod, Service, secret all GC). + +## Beat 2 — the real agent (goose + Bedrock) (4 min) + +The UI's create form doesn't yet set `models:`/`envFrom:` (known gap), so +create the real run via the API — which is the point: same CR, any client: + +```sh +kubectl create -f docs/demo/real-run.yaml +kubectl logs -f -n konveyor-agents -l konveyor.io/agentrun= --tail=50 # or: kubectl logs +``` + +Show the agent-base log lines: `cloning …coolstore@main`, `clone OK: … pom.xml …`, +`provider=aws_bedrock model=…haiku…`. That's the Phase-4 agent base doing +what the controller deliberately doesn't. + +Open the run in the browser UI and ask: + +> What build system does this project use? Name two files at the repository +> root that support your answer. + +Watch a **real tool call** (`tree /workspace`) and a grounded answer +(Maven, `pom.xml`). This is Claude on Bedrock reading the actual clone. + +## Beat 3 — the handoff (3 min) + +F5 the extension dev host with the **coolstore** workspace open. + +Within ~15s: a toast — *"Cluster agent real-XXXXX is running on this +workspace's repo (main). Attach to it?"* — the extension matched the run's +`repository` param against the workspace's git remote. + +Click **Attach**: the session history **replays** (`session/load`), +including the browser-side Q&A, and the conversation continues in the IDE +next to the actual code. Same run, same session, two shells. + +Also worth showing: `Konveyor: Attach to Cluster Agent for This Workspace` +in the palette (on-demand version of the toast). + +## Beat 4 — pull the plug (2 min) + +Failure handling, live. Create a fresh mock run (Beat 1 flow) and send: + +> connectivity drill: TEST_DROP + +The mock harness streams "about to lose connectivity", then destroys every +TCP connection it holds — pod-side death, mid-turn, prompt still pending. + +What the audience sees: within a second or two the chat flips to +**"Disconnected from the agent"** with a Reconnect button. Worth saying +what *didn't* happen: before the connection-death fix this exact prompt +hung the session forever — the port-forward tunnel swallowed the pod-side +teardown, so the browser stayed "connected" with a spinner and no error. +Now the tunnel mirrors the teardown onto the browser socket (clean 1011 +close in ~1s), with a ping/pong keepalive (`ACP_KEEPALIVE_MS`) as the +backstop for any silent-death mode the teardown misses. + +Click **Reconnect**: the pod is still alive (only its sockets died), so +the session reloads and the full history replays — the same `session/load` +path as Beat 3's handoff. Drop surfaced, drop recovered. + +Pre-verifiable like the rest: `npm run drop-check` in `packages/hub-shim` +(`dev/drop-check.ts`) runs this scenario end to end — throwaway mock run, +TEST_DROP prompt, browser-side 1011 required within budget — plus a +no-cluster direct-dial check. + +## Talking points + +- **Only one lane changes later**: browser clients (this SPA, tackle2-ui, + RHDH) all ride the gateway seat; hub-shim occupies it today, the real Hub + passthrough proxy replaces it — the shim's route table *is* the proposed + spec (`docs/adr/0004`, repo root). Hub already has the `/services/:name/*path` + precedent; stdlib ReverseProxy has done WS upgrades since Go 1.12. +- **Nobody rewrites UX**: the extension kept its panel/tree; tackle2-ui + gains chat capability it doesn't have (zero WS code today). +- **Contract is verified, not aspirational**: pod == `status.sandboxName`, + ACP key `secret-key`, headless portless Service, whole-spec immutability + — all proven against the live controller and encoded in the shared + client core. +- **Controller gaps this stack surfaced are upstreamed and merged**: CRD + CEL fixes, the scheme-builder cleanup, and (#34) sandbox pod run-labels + plus multi-variable (SigV4) provider credentials via keyless + `credentialRef` — the client-side `envFrom` workaround is gone from this + stack. + +## Cleanup + +```sh +kubectl delete agentrun --field-selector metadata.name= -n konveyor-agents # or by name +``` + +Idle goose costs nothing (Bedrock bills per request), so keeping the real +run alive as a standing demo target is fine. diff --git a/clients/docs/demo/real-run.yaml b/clients/docs/demo/real-run.yaml new file mode 100644 index 00000000..8d225b84 --- /dev/null +++ b/clients/docs/demo/real-run.yaml @@ -0,0 +1,19 @@ +apiVersion: konveyor.io/v1alpha1 +kind: AgentRun +metadata: + generateName: real- + namespace: konveyor-agents +spec: + agentRef: migration-analyzer-goose + params: + - name: repository + value: https://github.com/konveyor-ecosystem/coolstore + - name: branch + value: main + models: + - role: primary + provider: bedrock + model: "global.anthropic.claude-haiku-4-5-20251001-v1:0" + env: + - name: GOOSE_MODE + value: auto diff --git a/clients/docs/demo/skill-probe.yaml b/clients/docs/demo/skill-probe.yaml new file mode 100644 index 00000000..a8734c01 --- /dev/null +++ b/clients/docs/demo/skill-probe.yaml @@ -0,0 +1,41 @@ +# Skills-mount probe — reproduces the ImageVolume platform finding +# (docs/UPSTREAM-FEEDBACK.md item 5). +# +# Build the skill image first (content = any dir with a SKILL.md; PR #4's +# skills/examples/ejb-to-cdi works verbatim): +# printf 'FROM scratch\nCOPY . /\n' > Containerfile +# minikube image build -t skills-ejb-to-cdi:dev -f Containerfile . +# +# Then: kubectl apply -f this file, create an AgentRun with +# agentRef: skill-probe, and watch the pod. +# +# RESULT ON docker/cri-dockerd RUNTIMES (default minikube): the controller +# resolves everything (SkillCard Ready, Agent Ready, ImageVolume in the pod +# spec) but the pod sticks in CreateContainerError: +# Error response from daemon: invalid mount config for type "bind": +# field Source must not be empty +# ...while the AgentRun still reports phase=Running. Needs containerd >= 2.0 +# or CRI-O with image-volume support. +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: ejb-to-cdi + namespace: konveyor-agents +spec: + image: skills-ejb-to-cdi:dev + displayName: EJB to CDI Migration + description: Migrates EJB components to CDI managed beans. +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: skill-probe + namespace: konveyor-agents +spec: + image: acp-mock-harness:dev + prompt: Probe agent for the skills mount experiment. + providers: + - ref: mock-provider + skillCards: + - ref: ejb-to-cdi diff --git a/clients/hack/demo-check.sh b/clients/hack/demo-check.sh new file mode 100755 index 00000000..c6e48aab --- /dev/null +++ b/clients/hack/demo-check.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Pre-demo smoke: verify every surface the demo touches, then run the full +# protocol smoke (create mock run -> Running -> ACP prompt over WebSocket -> +# session/load replay -> delete). Exit 0 = safe to present. +# +# hack/demo-check.sh # after hack/demo-up.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SHIM_PORT="${SHIM_PORT:-7080}" +UI_PORT="${UI_PORT:-5199}" +NS=konveyor-agents + +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } +die() { printf ' \033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } + +echo "── surfaces ────────────────────────────────────────" +curl -sf --max-time 2 "http://127.0.0.1:$SHIM_PORT/healthz" >/dev/null \ + || die "shim not answering on :$SHIM_PORT — run hack/demo-up.sh" +ok "shim healthy (:$SHIM_PORT)" +if curl -sf --max-time 2 "http://127.0.0.1:$UI_PORT/" >/dev/null 2>&1; then + ok "ui serving (:$UI_PORT)" +else + warn "ui not answering on :$UI_PORT — browser beats will fail; run hack/demo-up.sh" +fi + +echo "── cluster ─────────────────────────────────────────" +avail=$(kubectl get deploy agentic-controller-controller-manager -n agentic-controller-system \ + -o jsonpath='{.status.conditions[?(@.type=="Available")].status}' 2>/dev/null || echo "") +[ "$avail" = "True" ] || die "controller not Available — run hack/demo-up.sh" +ok "controller Available" +ready=$(kubectl get agent migration-analyzer -n $NS \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "") +[ "$ready" = "True" ] || die "agent migration-analyzer not Ready — check: kubectl get llmproviders,agents -n $NS" +ok "agent migration-analyzer Ready" +gready=$(kubectl get agent migration-analyzer-goose -n $NS \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "") +if [ "$gready" = "True" ]; then + ok "agent migration-analyzer-goose Ready (real-agent beat available)" +else + warn "migration-analyzer-goose not Ready — beat 2 (goose+Bedrock) unavailable" +fi + +echo "── full protocol smoke (mock run, free) ────────────" +# create -> Running -> WS ACP -> prompt -> session/load replay -> delete, +# using only fetch + native WebSocket (browser constraints). +(cd "$ROOT/packages/hub-shim" && SHIM_URL="http://127.0.0.1:$SHIM_PORT" npx tsx dev/browser-smoke.ts) + +echo +ok "all checks passed — safe to present" diff --git a/clients/hack/demo-down.sh b/clients/hack/demo-down.sh new file mode 100755 index 00000000..e272e1f4 --- /dev/null +++ b/clients/hack/demo-down.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Stop the demo's local processes (shim + ui). The cluster is untouched — +# use `minikube stop` for that, and `kubectl delete agentruns -n +# konveyor-agents ` for runs you created. +set -uo pipefail + +for name in hub-shim ui hub-pf; do + pidfile="/tmp/demo-$name.pid" + if [ -f "$pidfile" ]; then + pid=$(cat "$pidfile") + # npm wraps the real server: kill children FIRST — killing the parent + # first reparents them to init and they survive pkill -P. + pkill -P "$pid" >/dev/null 2>&1 || true + if kill "$pid" >/dev/null 2>&1; then + echo "stopped $name (pid $pid)" + else + echo "$name (pid $pid) was not running" + fi + rm -f "$pidfile" + else + echo "$name: no pidfile — nothing to stop" + fi +done + +# The Hub port-forward runs in a restart loop; killing the wrapper can leave +# an orphaned kubectl that would keep the local port bound. Sweep it. +pkill -f 'kubectl port-forward.*tackle2-hub' >/dev/null 2>&1 && echo "swept hub port-forward" || true diff --git a/clients/hack/demo-up.sh b/clients/hack/demo-up.sh new file mode 100755 index 00000000..82b14f93 --- /dev/null +++ b/clients/hack/demo-up.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# One-command demo bring-up: converge the cluster, start the shim + UI. +# Idempotent — safe to re-run after a reboot or `minikube stop`. +# +# hack/demo-up.sh # bring everything up +# hack/demo-down.sh # stop the local processes (cluster untouched) +# +# Logs: /tmp/demo-hub-shim.log, /tmp/demo-ui.log +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SHIM_PORT="${SHIM_PORT:-7080}" +UI_PORT="${UI_PORT:-5199}" +NS=konveyor-agents +# Real Konveyor Hub the shim reads the application inventory from. A +# port-forward gives the laptop a stable HUB_URL; in-cluster this would be +# the Hub service DNS. If Hub is unreachable the shim falls back to a stub. +HUB_NS="${HUB_NS:-konveyor-tackle}" +HUB_SVC="${HUB_SVC:-tackle-hub}" +HUB_LOCAL_PORT="${HUB_LOCAL_PORT:-18090}" + +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } +die() { printf ' \033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } + +echo "── cluster ──────────────────────────────────────────" +minikube status >/dev/null 2>&1 || die "minikube is not running — start it: minikube start" +[ "$(kubectl config current-context)" = "minikube" ] || die "kubectl context is not minikube" +ok "minikube up, context correct" + +kubectl get deploy agent-sandbox-controller -n agent-sandbox-system >/dev/null 2>&1 \ + || die "Agent Sandbox missing — install v0.5.0 (helm chart from kubernetes-sigs/agent-sandbox)" +ok "Agent Sandbox present" + +echo "── controller (this repo) ──────────────────────────" +if ! kubectl get deployment agentic-controller-controller-manager \ + -n agentic-controller-system >/dev/null 2>&1; then + die "agentic-controller not deployed — from the repo root: make docker-build deploy IMG=agentic-controller:dev (build into minikube: eval \$(minikube docker-env) first, or minikube image load agentic-controller:dev)" +fi +kubectl wait deployment/agentic-controller-controller-manager \ + -n agentic-controller-system --for=condition=Available --timeout=120s >/dev/null +ok "controller Available" + +echo "── agent-base images ───────────────────────────────" +if ! minikube image ls 2>/dev/null | grep -q 'acp-mock-harness:dev'; then + warn "building acp-mock-harness:dev" + (cd "$ROOT/harness-mock" && minikube image build -t acp-mock-harness:dev -f Dockerfile . >/dev/null) +fi +ok "acp-mock-harness:dev" +if ! minikube image ls 2>/dev/null | grep -q 'goose-harness:dev'; then + warn "building goose-harness:dev" + (cd "$ROOT/harness-goose" && minikube image build -t goose-harness:dev -f Dockerfile . >/dev/null) +fi +ok "goose-harness:dev" + +echo "── sample resources ────────────────────────────────" +kubectl apply -f "$ROOT/manifests/samples.yaml" >/dev/null +ok "samples applied (mock agent + provider)" +kubectl apply -f "$ROOT/manifests/image-catalog.yaml" >/dev/null +ok "agent-image catalog applied (GET /api/images serves it)" +if kubectl get secret aws-bedrock-creds -n $NS >/dev/null 2>&1; then + kubectl apply -f "$ROOT/manifests/goose-bedrock.yaml" >/dev/null + ok "goose-bedrock applied (aws-bedrock-creds present)" +else + warn "aws-bedrock-creds secret missing — skipping goose-bedrock.yaml (mock-only demo); see manifests/goose-bedrock.yaml header to create it" +fi + +# Readiness gate: providers verify via a Job, then agents flip Ready. +if kubectl wait agent/migration-analyzer -n $NS \ + --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True --timeout=90s >/dev/null 2>&1; then + ok "agent migration-analyzer Ready" +else + warn "migration-analyzer not Ready yet — check: kubectl get llmproviders,agents -n $NS" +fi + +echo "── konveyor hub (application inventory) ────────────" +HUB_URL="http://127.0.0.1:$HUB_LOCAL_PORT" +if curl -sf --max-time 2 "$HUB_URL/applications" >/dev/null 2>&1; then + ok "Hub reachable on :$HUB_LOCAL_PORT — reusing" +elif kubectl get svc "$HUB_SVC" -n "$HUB_NS" >/dev/null 2>&1; then + # kubectl port-forward drops on "lost connection to pod" — wrap it in a + # restart loop so a mid-demo drop self-heals (the shim re-fetches per + # request, so it recovers as soon as the forward is back). + nohup bash -c "while true; do kubectl port-forward -n '$HUB_NS' 'svc/$HUB_SVC' '$HUB_LOCAL_PORT:8080'; sleep 1; done" \ + /tmp/demo-hub-pf.log 2>&1 & + echo $! > /tmp/demo-hub-pf.pid + for _ in $(seq 1 20); do + curl -sf --max-time 1 "$HUB_URL/applications" >/dev/null 2>&1 && break + sleep 0.5 + done + if curl -sf --max-time 2 "$HUB_URL/applications" >/dev/null 2>&1; then + ok "port-forward $HUB_NS/$HUB_SVC -> :$HUB_LOCAL_PORT (pid $(cat /tmp/demo-hub-pf.pid))" + else + warn "Hub port-forward not ready — shim will use its offline stub" + HUB_URL="" + fi +else + warn "no $HUB_SVC in ns $HUB_NS — shim will use its offline stub" + HUB_URL="" +fi + +echo "── hub-shim (:$SHIM_PORT) ──────────────────────────" +if curl -sf --max-time 2 "http://127.0.0.1:$SHIM_PORT/healthz" >/dev/null 2>&1; then + ok "already serving — reusing" +else + [ -d "$ROOT/packages/hub-shim/node_modules" ] \ + || (cd "$ROOT/packages/hub-shim" && npm install > /tmp/demo-hub-shim-install.log 2>&1) \ + || die "npm install failed in packages/hub-shim — /tmp/demo-hub-shim-install.log" + # Background ONLY the nohup command (an `&` after a `cmd1 && cmd2` list + # would fork a lingering wrapper shell that holds our stdout open and + # poisons the pidfile). + ( + cd "$ROOT/packages/hub-shim" + PORT=$SHIM_PORT HUB_URL=$HUB_URL nohup npm start /tmp/demo-hub-shim.log 2>&1 & + echo $! > /tmp/demo-hub-shim.pid + ) + for _ in $(seq 1 20); do + curl -sf --max-time 1 "http://127.0.0.1:$SHIM_PORT/healthz" >/dev/null 2>&1 && break + sleep 0.5 + done + curl -sf --max-time 2 "http://127.0.0.1:$SHIM_PORT/healthz" >/dev/null || die "shim failed — /tmp/demo-hub-shim.log" + ok "started (pid $(cat /tmp/demo-hub-shim.pid), log /tmp/demo-hub-shim.log)" +fi + +echo "── ui (:$UI_PORT) ──────────────────────────────────" +if curl -sf --max-time 2 "http://127.0.0.1:$UI_PORT/" >/dev/null 2>&1; then + ok "already serving — reusing" +else + [ -d "$ROOT/ui/node_modules" ] \ + || (cd "$ROOT/ui" && npm install > /tmp/demo-ui-install.log 2>&1) \ + || die "npm install failed in ui — /tmp/demo-ui-install.log" + ( + cd "$ROOT/ui" + VITE_SHIM_URL="http://127.0.0.1:$SHIM_PORT" nohup npm run dev -- --host 127.0.0.1 --port "$UI_PORT" --strictPort /tmp/demo-ui.log 2>&1 & + echo $! > /tmp/demo-ui.pid + ) + for _ in $(seq 1 30); do + curl -sf --max-time 1 "http://127.0.0.1:$UI_PORT/" >/dev/null 2>&1 && break + sleep 0.5 + done + curl -sf --max-time 2 "http://127.0.0.1:$UI_PORT/" >/dev/null || die "ui failed — /tmp/demo-ui.log" + ok "started (pid $(cat /tmp/demo-ui.pid), log /tmp/demo-ui.log)" +fi + +echo +echo "ready:" +echo " ui http://localhost:$UI_PORT" +echo " shim http://127.0.0.1:$SHIM_PORT/healthz" +echo " real run kubectl create -f docs/demo/real-run.yaml (goose+Bedrock; needs aws-bedrock-creds)" +echo " note don't rely on runs across a minikube restart —" +echo " create a fresh run right before presenting." +echo " script docs/DEMO.md" diff --git a/clients/harness-goose/Dockerfile b/clients/harness-goose/Dockerfile new file mode 100644 index 00000000..fdc80734 --- /dev/null +++ b/clients/harness-goose/Dockerfile @@ -0,0 +1,51 @@ +# DEPRECATED (2026-07): superseded by the Go migration-harness image +# hierarchy (images/agent-base + agent-, PR #53) — that harness pulls +# the application from the Hub itself (clone-from-param is gone), builds the +# prompt from mounted skills, and owns the ACP session. This image remains +# only until #53's images publish; delete it when PR #35 rebases onto +# merged #53. Do not extend it. +# +# Real harness image: goose v1.39.0 serving ACP over HTTP/WebSocket. +# v1.39.0 = plain HTTP (the self-signed-TLS default landed later), auth via +# X-Secret-Key header or ?token= query param, per ADR 0002. +FROM debian:bookworm-slim + +ARG GOOSE_VERSION=v1.39.0 +# Release-triple override; when empty the RUN below derives it from +# TARGETARCH (BuildKit) or dpkg (classic builder). +ARG GOOSE_ARCH= +ARG TARGETARCH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl bzip2 git libxcb1 \ + && rm -rf /var/lib/apt/lists/* + +RUN arch="${GOOSE_ARCH}"; \ + if [ -z "$arch" ]; then \ + case "${TARGETARCH:-$(dpkg --print-architecture)}" in \ + amd64) arch=x86_64-unknown-linux-gnu ;; \ + arm64) arch=aarch64-unknown-linux-gnu ;; \ + *) echo "unsupported arch '${TARGETARCH}' — set GOOSE_ARCH explicitly" >&2; exit 1 ;; \ + esac; \ + fi; \ + curl -fsSL \ + "https://github.com/aaif-goose/goose/releases/download/${GOOSE_VERSION}/goose-${arch}.tar.bz2" \ + -o /tmp/goose.tar.bz2 \ + && tar -xjf /tmp/goose.tar.bz2 -C /usr/local/bin \ + && rm /tmp/goose.tar.bz2 \ + && chmod +x /usr/local/bin/goose \ + && goose --version + +# Headless defaults; the controller overrides per run. +ENV GOOSE_DISABLE_KEYRING=1 \ + GOOSE_MODE=auto \ + HOME=/root + +# Agent-base entrypoint: clones the run's repository into /workspace and +# maps the controller's KONVEYOR_* env contract onto goose before serving. +COPY entrypoint.sh /usr/local/bin/agent-entrypoint.sh +RUN chmod +x /usr/local/bin/agent-entrypoint.sh + +WORKDIR /workspace +EXPOSE 4000 +CMD ["/usr/local/bin/agent-entrypoint.sh"] diff --git a/clients/harness-goose/entrypoint.sh b/clients/harness-goose/entrypoint.sh new file mode 100644 index 00000000..df9d6c05 --- /dev/null +++ b/clients/harness-goose/entrypoint.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Phase-4 agent base entrypoint: adapt the agentic-controller's KONVEYOR_* +# env contract to goose, then serve ACP. +# +# The controller injects: KONVEYOR_PARAM_* (run params), +# KONVEYOR_MODEL__{PROVIDER,MODEL,ENDPOINT,API_KEY} (model selections), +# KONVEYOR_PROMPT / KONVEYOR_INSTRUCTIONS, GOOSE_SERVER__SECRET_KEY, +# plus run.spec.env / run.spec.envFrom passthrough. It does NOT clone the +# repository (the retired dev simulator did that in an init container) — +# the agent base owns workspace setup now. +# +# Credentials: API_KEY is injected only when the provider's credentialRef +# names a key (OpenAI-style bearer token). For SigV4 (Bedrock), use a +# keyless credentialRef — the controller exposes the whole credential +# secret (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) to this +# container via envFrom (#34). +set -u + +log() { echo "[agent-base] $*"; } + +# 1. Workspace: clone the target repo into the controller's EmptyDir. +REPO="${KONVEYOR_PARAM_REPOSITORY:-}" +BRANCH="${KONVEYOR_PARAM_BRANCH:-main}" +if [ -n "$REPO" ]; then + if [ -z "$(ls -A /workspace 2>/dev/null)" ]; then + log "cloning $REPO@$BRANCH into /workspace" + if git clone --depth 1 --branch "$BRANCH" "$REPO" /workspace 2>&1; then + log "clone OK: $(ls /workspace | head -6 | tr '\n' ' ')" + else + log "WARNING: clone failed — agent starts with an empty workspace" + fi + else + log "workspace not empty — skipping clone" + fi +else + log "no repository param — skipping clone" +fi + +# 2. Model: map KONVEYOR_MODEL_PRIMARY_* onto goose env. Explicit +# GOOSE_PROVIDER / GOOSE_MODEL (from run.spec.env) win. The provider +# value is the LLMProvider CR *name*; map well-known names onto goose +# provider implementations, else pass through verbatim. +if [ -z "${GOOSE_MODEL:-}" ] && [ -n "${KONVEYOR_MODEL_PRIMARY_MODEL:-}" ]; then + GOOSE_MODEL="$KONVEYOR_MODEL_PRIMARY_MODEL" + export GOOSE_MODEL +fi +if [ -z "${GOOSE_PROVIDER:-}" ] && [ -n "${KONVEYOR_MODEL_PRIMARY_PROVIDER:-}" ]; then + case "$KONVEYOR_MODEL_PRIMARY_PROVIDER" in + *bedrock*) GOOSE_PROVIDER=aws_bedrock ;; + *anthropic*) GOOSE_PROVIDER=anthropic ;; + *openai*) GOOSE_PROVIDER=openai ;; + *ollama*) GOOSE_PROVIDER=ollama ;; + *) GOOSE_PROVIDER="$KONVEYOR_MODEL_PRIMARY_PROVIDER" ;; + esac + export GOOSE_PROVIDER +fi +log "provider=${GOOSE_PROVIDER:-unset} model=${GOOSE_MODEL:-unset}" +if [ "${GOOSE_PROVIDER:-}" = "aws_bedrock" ] && [ -z "${AWS_ACCESS_KEY_ID:-}" ]; then + log "WARNING: aws_bedrock selected but AWS_ACCESS_KEY_ID is unset — use a keyless credentialRef on the LLMProvider so the controller mounts the credential secret" +fi + +# 3. Standing prompt + instructions -> .goosehints in the workspace +# (sessions run with cwd /workspace; goose reads hints from there). +if [ -n "${KONVEYOR_PROMPT:-}${KONVEYOR_INSTRUCTIONS:-}" ]; then + { + [ -n "${KONVEYOR_PROMPT:-}" ] && printf '%s\n' "$KONVEYOR_PROMPT" + [ -n "${KONVEYOR_INSTRUCTIONS:-}" ] && printf '\n%s\n' "$KONVEYOR_INSTRUCTIONS" + true # group status must reflect the redirect, not the last [ -n ] test + } > /workspace/.goosehints 2>/dev/null && log "wrote /workspace/.goosehints" \ + || log "WARNING: could not write /workspace/.goosehints" +fi + +# 4. Skills: the controller mounts each resolved SkillCard as an ImageVolume +# at /opt/skills//. Fold every skill's SKILL.md into the hints so +# the agent actually knows its skills. (Requires a runtime with k8s +# ImageVolume support — containerd >= 2.0 / CRI-O; docker/cri-dockerd +# pods fail with CreateContainerError before we ever run.) +if [ -d /opt/skills ]; then + for d in /opt/skills/*/; do + [ -f "${d}SKILL.md" ] || continue + name="$(basename "$d")" + { + printf '\n\n## Skill: %s (files under %s)\n\n' "$name" "$d" + cat "${d}SKILL.md" + } >> /workspace/.goosehints 2>/dev/null \ + && log "folded skill '$name' into .goosehints" \ + || log "WARNING: could not fold skill '$name'" + done +fi + +exec goose serve --host 0.0.0.0 --port 4000 diff --git a/clients/harness-mock/Dockerfile b/clients/harness-mock/Dockerfile new file mode 100644 index 00000000..43a0fb63 --- /dev/null +++ b/clients/harness-mock/Dockerfile @@ -0,0 +1,7 @@ +FROM node:24-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server.mjs ./ +EXPOSE 4000 +CMD ["node", "server.mjs"] diff --git a/clients/harness-mock/package-lock.json b/clients/harness-mock/package-lock.json new file mode 100644 index 00000000..1633c477 --- /dev/null +++ b/clients/harness-mock/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "acp-mock-harness", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "acp-mock-harness", + "version": "0.1.0", + "dependencies": { + "@agentclientprotocol/sdk": "1.1.0", + "ws": "^8.21.0" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/clients/harness-mock/package.json b/clients/harness-mock/package.json new file mode 100644 index 00000000..be3fe4c4 --- /dev/null +++ b/clients/harness-mock/package.json @@ -0,0 +1,10 @@ +{ + "name": "acp-mock-harness", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@agentclientprotocol/sdk": "1.1.0", + "ws": "^8.21.0" + } +} diff --git a/clients/harness-mock/server.mjs b/clients/harness-mock/server.mjs new file mode 100644 index 00000000..4358c7f7 --- /dev/null +++ b/clients/harness-mock/server.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +/** + * Mock of the sandbox harness ACP surface (`goose serve --port 4000`). + * + * Speaks real ACP — it is built on @agentclientprotocol/sdk's server side, + * the same protocol implementation the client uses — but fakes the agent: + * no LLM calls, deterministic streamed responses. Honors the ADR 0002 + * contract: + * - WebSocket + streamable HTTP at :4000/acp + * - X-Secret-Key auth from GOOSE_SERVER__SECRET_KEY + * - session/new, session/load (full history replay), session/prompt, + * session/cancel + * - GOOSE_MODE=auto suppresses request_permission + * + * Replace with the real harness image when the controller lands; nothing + * in the client changes. + * + * Failure modes for integration tests, triggered by tokens in the prompt: + * TEST_PERMISSION — force a session/request_permission round-trip + * (even in GOOSE_MODE=auto) and echo the outcome + * TEST_CANCEL — stream slowly until session/cancel arrives + * TEST_DROP — destroy all TCP connections mid-turn (the pending + * prompt request never gets a response) + */ +import http from "node:http"; +import { WebSocketServer } from "ws"; +import * as acp from "@agentclientprotocol/sdk"; +import { AcpServer } from "@agentclientprotocol/sdk/experimental/server"; +import { + createNodeHttpHandler, + createNodeWebSocketUpgradeHandler, +} from "@agentclientprotocol/sdk/experimental/node"; + +const PORT = Number(process.env.PORT ?? 4000); +const SECRET_KEY = process.env.GOOSE_SERVER__SECRET_KEY ?? ""; +const GOOSE_MODE = process.env.GOOSE_MODE ?? "auto"; +// The real agentic-controller injects KONVEYOR_PROMPT / KONVEYOR_INSTRUCTIONS +// (from Agent.spec.prompt and AgentRun.spec.instructions). Fall back to the +// AGENT_* names for older/standalone invocations. +const AGENT_PROMPT = + process.env.KONVEYOR_PROMPT ?? process.env.AGENT_PROMPT ?? "(no standing prompt)"; +const AGENT_INSTRUCTIONS = + process.env.KONVEYOR_INSTRUCTIONS ?? process.env.AGENT_INSTRUCTIONS ?? ""; + +const params = Object.entries(process.env) + .filter(([k]) => k.startsWith("KONVEYOR_PARAM_")) + .map(([k, v]) => `${k.slice("KONVEYOR_PARAM_".length).toLowerCase()}=${v}`); + +/** sessionId -> { history: SessionNotification[] } (stand-in for goose's SQLite) */ +const sessions = new Map(); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const newId = () => + Array.from(crypto.getRandomValues(new Uint8Array(12))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + +async function notifyAndRecord(cx, session, sessionId, update) { + const notification = { sessionId, update }; + session.history.push(notification); + await cx.client.notify(acp.methods.client.session.update, notification); +} + +function buildAgent() { + return acp + .agent({ name: "mock-goose-harness" }) + .onRequest(acp.methods.agent.initialize, () => ({ + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { loadSession: true }, + })) + .onRequest(acp.methods.agent.authenticate, () => ({})) + .onRequest(acp.methods.agent.session.new, () => { + const sessionId = newId(); + sessions.set(sessionId, { history: [] }); + console.log(`[mock-harness] session/new -> ${sessionId}`); + return { sessionId }; + }) + .onRequest(acp.methods.agent.session.load, async (cx) => { + const session = sessions.get(cx.params.sessionId); + if (!session) throw new Error(`unknown session ${cx.params.sessionId}`); + console.log( + `[mock-harness] session/load ${cx.params.sessionId}: replaying ${session.history.length} updates`, + ); + for (const notification of session.history) { + await cx.client.notify(acp.methods.client.session.update, notification); + } + return {}; + }) + .onNotification(acp.methods.agent.session.cancel, (cx) => { + const session = sessions.get(cx.params.sessionId); + if (session) session.cancelled = true; + console.log(`[mock-harness] session/cancel ${cx.params.sessionId}`); + }) + .onRequest(acp.methods.agent.session.prompt, async (cx) => { + const { sessionId, prompt } = cx.params; + const session = sessions.get(sessionId); + if (!session) throw new Error(`unknown session ${sessionId}`); + session.cancelled = false; + + const userText = prompt + .map((block) => (block.type === "text" ? block.text : `[${block.type}]`)) + .join(" "); + console.log(`[mock-harness] session/prompt ${sessionId}: ${userText}`); + + const say = (text) => + notifyAndRecord(cx, session, sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); + + await say(`Mock harness online. Standing prompt: ${AGENT_PROMPT.trim().slice(0, 80)}\n`); + await sleep(150); + if (AGENT_INSTRUCTIONS.trim()) { + await say(`Run instructions: ${AGENT_INSTRUCTIONS.trim().slice(0, 120)}\n`); + await sleep(150); + } + await say(`Run params: ${params.length ? params.join(", ") : "(none)"}\n`); + await sleep(150); + + await notifyAndRecord(cx, session, sessionId, { + sessionUpdate: "tool_call", + toolCallId: "call_1", + title: "Scanning workspace", + kind: "read", + status: "in_progress", + locations: [{ path: "/workspace" }], + }); + await sleep(300); + await notifyAndRecord(cx, session, sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: "call_1", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "Found 42 source files (mock)." }, + }, + ], + }); + + if (GOOSE_MODE !== "auto" || userText.includes("TEST_PERMISSION")) { + const response = await cx.client.request(acp.methods.client.session.requestPermission, { + sessionId, + toolCall: { + toolCallId: "call_2", + title: "Write findings to .konveyor/", + kind: "edit", + // Standard ACP ToolCallContent diff blocks — the diff-preview + // payload UIs render before approving. + content: [ + { + type: "diff", + path: "src/main/java/com/example/InventoryService.java", + oldText: [ + "import javax.ejb.Stateless;", + "import javax.persistence.EntityManager;", + "", + "@Stateless", + "public class InventoryService {", + ].join("\n"), + newText: [ + "import jakarta.ejb.Stateless;", + "import jakarta.persistence.EntityManager;", + "", + "@Stateless", + "public class InventoryService {", + ].join("\n"), + }, + { + type: "diff", + path: ".konveyor/java-ee-findings.md", + oldText: null, // new file + newText: "# Findings\n\n- javax.* imports: 2 (map to jakarta.*)\n", + }, + ], + }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }); + await say(`Permission outcome: ${JSON.stringify(response.outcome)}\n`); + } + + if (userText.includes("TEST_CANCEL")) { + for (let i = 0; i < 50 && !session.cancelled; i++) { + await say(`still working (${i})...\n`); + await sleep(200); + } + } + + if (userText.includes("TEST_DROP")) { + await say("about to lose connectivity\n"); + setTimeout(() => { + console.log(`[mock-harness] TEST_DROP: destroying ${rawSockets.size} connection(s)`); + for (const s of rawSockets) s.destroy(); + }, 100); + await sleep(10_000); // response never reaches the client + } + + if (session.cancelled) return { stopReason: "cancelled" }; + + await say(`Echo of your instructions: "${userText}"\n`); + await say("Turn complete.\n"); + return { stopReason: "end_turn" }; + }); +} + +const acpServer = new AcpServer({ createAgent: buildAgent }); +const httpHandler = createNodeHttpHandler(acpServer); +const wss = new WebSocketServer({ noServer: true }); +const upgradeHandler = createNodeWebSocketUpgradeHandler(acpServer, wss); + +function authorized(req) { + if (!SECRET_KEY) return true; // no key configured = open (dev only) + return req.headers["x-secret-key"] === SECRET_KEY; +} + +const server = http.createServer((req, res) => { + if (req.url === "/healthz") { + res.writeHead(200).end("ok"); + return; + } + if (!authorized(req)) { + res.writeHead(401, { "content-type": "text/plain" }).end("invalid X-Secret-Key"); + return; + } + httpHandler(req, res); +}); + +const rawSockets = new Set(); +server.on("connection", (socket) => { + rawSockets.add(socket); + socket.on("close", () => rawSockets.delete(socket)); +}); + +server.on("upgrade", (req, socket, head) => { + if (!authorized(req)) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + upgradeHandler(req, socket, head); +}); + +server.listen(PORT, () => { + console.log( + `[mock-harness] ACP listening on :${PORT}/acp (auth=${SECRET_KEY ? "X-Secret-Key" : "OPEN"}, GOOSE_MODE=${GOOSE_MODE})`, + ); +}); diff --git a/clients/manifests/goose-bedrock.yaml b/clients/manifests/goose-bedrock.yaml new file mode 100644 index 00000000..9468df77 --- /dev/null +++ b/clients/manifests/goose-bedrock.yaml @@ -0,0 +1,94 @@ +# Real migration agent wired to AWS Bedrock, in the PR #53 (migration- +# harness) shape: the provider CR NAME maps verbatim to a goose provider id +# (lowercased, "-" -> "_": aws-bedrock -> aws_bedrock), the agent runs a +# harness image and mounts skills as OCI image volumes, and the run supplies +# Hub coordinates + TARGET_BRANCH via spec.env (the hub-shim injects them +# from applicationRef). +# +# Images: quay.io/konveyor/agent-java + quay.io/konveyor/skills:* publish +# from CI once #53 merges; until then build them locally with +# agentic-controller's hack/harness-test/setup.sh (kind) or the equivalent +# minikube image build. +# +# The aws-bedrock-creds secret is created separately so credentials never +# land in this file: +# kubectl create secret generic aws-bedrock-creds -n konveyor-agents \ +# --from-literal=AWS_ACCESS_KEY_ID=... \ +# --from-literal=AWS_SECRET_ACCESS_KEY=... \ +# --from-literal=AWS_REGION=us-east-1 +--- +apiVersion: konveyor.io/v1alpha1 +kind: LLMProvider +metadata: + name: aws-bedrock + namespace: konveyor-agents +spec: + endpoint: "https://bedrock-runtime.us-east-1.amazonaws.com" + # Keyless: with key omitted the controller exposes the whole Secret to the + # sandbox via envFrom — required for SigV4 providers, which need the + # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION triple, not a + # single bearer token. + credentialRef: + secretName: aws-bedrock-creds + models: + - name: "global.anthropic.claude-haiku-4-5-20251001-v1:0" + contextWindow: 200000 + tier: efficient + - name: "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + contextWindow: 200000 + tier: primary +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: execute + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +spec: + displayName: Execute migration + description: Stage skill — read PLAN.md and migrate file-by-file, no builds. + type: skill + image: "quay.io/konveyor/skills:execute" +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javaee-to-quarkus + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +spec: + displayName: Java EE to Quarkus + description: Domain skill — annotation/config/dependency maps for the Quarkus target. + type: skill + image: "quay.io/konveyor/skills:javaee-to-quarkus" +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: java-migrator-goose + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +spec: + # agent-base + JDK 21/Maven; entrypoint is the migration-harness binary, + # which pulls the application (repo, creds, analysis) from the Hub itself + # using the run's injected HUB_BASE_URL/HUB_TOKEN/APP_ID, clones to + # /workspace/repo, and withholds the git credentials from the agent. + image: "quay.io/konveyor/agent-java:dev" + prompt: | + You are a careful Java migration agent. Work only on the checked-out + repository; commit nothing yourself — the harness owns git. + providers: + - ref: aws-bedrock + # Zero mounted skills is a fatal harness error — every migration agent + # pairs at least one stage skill with a domain skill. + skillCards: + - ref: execute + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + description: Cap on agent turns for the stage + default: "200" diff --git a/clients/manifests/image-catalog.yaml b/clients/manifests/image-catalog.yaml new file mode 100644 index 00000000..776e97b8 --- /dev/null +++ b/clients/manifests/image-catalog.yaml @@ -0,0 +1,55 @@ +# Agent-image catalog — the "resource to track images" (Phase 2). +# +# A managed ConfigMap, not a CRD: the catalog is cluster data an admin can +# edit without a controller rollout. The shim serves it at GET /api/images +# (falling back to the same built-in list when this ConfigMap is absent), +# and POST /api/defaults creates it if missing. Propose an AgentImage CRD +# upstream only if this proves insufficient. +# +# Content mirrors the upstream image hierarchy (images/README.md on #53): +# agent-base + per-language derivatives. Keep in sync with +# DEFAULT_IMAGE_CATALOG in packages/hub-shim/src/defaults.ts. +apiVersion: v1 +kind: ConfigMap +metadata: + name: agent-image-catalog + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +data: + catalog.json: | + [ + { + "image": "quay.io/konveyor/agent-base:dev", + "displayName": "Agent base", + "notes": "UBI 10 + goose CLI + git + Python 3 + graphify + migration-harness binary" + }, + { + "image": "quay.io/konveyor/agent-java:dev", + "displayName": "Java agent", + "language": "java", + "base": "quay.io/konveyor/agent-base:dev", + "notes": "adds JDK 21 and Maven" + }, + { + "image": "quay.io/konveyor/agent-go:dev", + "displayName": "Go agent", + "language": "go", + "base": "quay.io/konveyor/agent-base:dev", + "notes": "adds the Go toolchain" + }, + { + "image": "quay.io/konveyor/agent-csharp:dev", + "displayName": "C# agent", + "language": "csharp", + "base": "quay.io/konveyor/agent-base:dev", + "notes": "adds the .NET SDK" + }, + { + "image": "quay.io/konveyor/agent-nodejs:dev", + "displayName": "Node.js agent", + "language": "nodejs", + "base": "quay.io/konveyor/agent-base:dev", + "notes": "adds Node.js and npm" + } + ] diff --git a/clients/manifests/samples.yaml b/clients/manifests/samples.yaml new file mode 100644 index 00000000..37bdf481 --- /dev/null +++ b/clients/manifests/samples.yaml @@ -0,0 +1,118 @@ +# Sample resources for the agentrun-client demo against minikube. +# Mirrors what Hub (per ADR 0003) or an admin would create. +--- +apiVersion: v1 +kind: Secret +metadata: + name: openai-compatible-credentials + namespace: konveyor-agents +type: Opaque +stringData: + apiKey: "not-a-real-key-mock-harness-never-calls-an-llm" +--- +apiVersion: konveyor.io/v1alpha1 +kind: LLMProvider +metadata: + name: mock-provider + namespace: konveyor-agents +spec: + # The real controller's verification Job is a reachability probe (any + # HTTP 2xx-5xx passes, no auth). The in-cluster apiserver always answers + # 403, needs no egress, and the mock harness never calls an LLM anyway. + # A dead/unresolvable endpoint here leaves the provider NotReady and + # blocks every AgentRun with AgentNotReady. + endpoint: "https://kubernetes.default.svc" + credentialRef: + secretName: openai-compatible-credentials + key: apiKey + models: + - name: mock-model-large + contextWindow: 200000 + tier: primary + - name: mock-model-small + contextWindow: 32000 + tier: efficient +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-analyzer + namespace: konveyor-agents + labels: + # Konveyor UIs list only managed agents (hub-shim filters on this). + konveyor.io/managed: "true" +spec: + # In the real system this is the harness image (goose + toolchains). + # The controller simulator substitutes the mock ACP harness image. The + # mock is a FIXTURE: it runs without an application and without skills — + # real migration agents need both (the migration-harness pulls the + # application from the Hub and fatals with zero mounted skills). + image: "acp-mock-harness:dev" + prompt: | + You are a migration analysis agent. Analyze the application source + in /workspace and produce findings under .konveyor/. + providers: + - ref: mock-provider + params: + - name: repository + type: string + description: Git URL the mock pretends to analyze (fixtures only) + default: "https://github.com/konveyor-ecosystem/coolstore.git" + - name: branch + type: string + description: Branch to analyze + default: main +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: analyze-java-ee + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +spec: + displayName: Analyze Java EE application + description: Identify Java EE APIs in use and map each to its Jakarta/cloud-native replacement. + type: skill + version: 0.1.0 + tags: [java, migration, analysis] + # Exactly one of image | source | inline (CEL-enforced). Image refs are + # accepted as-is today (Ready=True); the ref is not pulled or validated. + image: "quay.io/konveyor/skill-analyze-java-ee:v0.1.0" +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: cloud-readiness-rules + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +spec: + displayName: Cloud readiness ground rules + description: Always-loaded constraints the agent must respect while analyzing. + type: rule + version: 0.1.0 + tags: [migration, policy] + # Inline stays NotReady (InlineNotSupported) until upstream Phase 3 lands. + # Kept inline on purpose: sample data should exercise both Ready states. + inline: | + # Cloud readiness ground rules + + - Never modify application source; analysis is read-only. + - Flag any local-filesystem or hardcoded-hostname dependency. + - Prefer configuration over code changes in every recommendation. +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCollection +metadata: + name: migration-skills + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" +spec: + # Each entry references exactly one of skillCardRef | image | source. + skills: + - name: analyze-java-ee + skillCardRef: analyze-java-ee + - name: cloud-readiness-rules + skillCardRef: cloud-readiness-rules diff --git a/clients/packages/agentic-client/dev/selfcheck.ts b/clients/packages/agentic-client/dev/selfcheck.ts new file mode 100644 index 00000000..28f54650 --- /dev/null +++ b/clients/packages/agentic-client/dev/selfcheck.ts @@ -0,0 +1,228 @@ +/** + * Pure unit checks — no network, no cluster. Run: npx tsx dev/selfcheck.ts + * + * Covers resolveSecretKeyFromData (both key names, precedence, sole-entry + * fallback, error paths, utf-8), ShimClient.acpUrl derivation, + * isTerminalPhase, and waitForRunning against an in-memory fake RunApi. + */ +import { + isTerminalPhase, + resolveSecretKeyFromData, + waitForRunning, + type AgentRun, + type RunApi, +} from "../src/contract/index.js"; +import { ShimClient } from "../src/transport-shim/index.js"; + +let passed = 0; +let failed = 0; + +function check(name: string, fn: () => void | Promise): Promise { + return Promise.resolve() + .then(fn) + .then(() => { + passed++; + console.log(` ok ${name}`); + }) + .catch((err: unknown) => { + failed++; + console.error(` FAIL ${name}: ${err instanceof Error ? err.message : String(err)}`); + }); +} + +function assertEqual(actual: unknown, expected: unknown, label = "value"): void { + if (actual !== expected) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +async function assertRejects(p: Promise, contains: string[]): Promise { + try { + await p; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + for (const fragment of contains) { + if (!msg.includes(fragment)) { + throw new Error(`rejection message missing "${fragment}": ${msg}`); + } + } + return; + } + throw new Error("expected rejection, but promise resolved"); +} + +function assertThrows(fn: () => unknown, contains: string[]): void { + try { + fn(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + for (const fragment of contains) { + if (!msg.includes(fragment)) { + throw new Error(`error message missing "${fragment}": ${msg}`); + } + } + return; + } + throw new Error("expected throw, but function returned"); +} + +/** base64 of a utf-8 string, browser-style (no Buffer). */ +function b64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +async function main(): Promise { + console.log("resolveSecretKeyFromData"); + await check("decodes 'secret-key' (real controller)", () => { + assertEqual(resolveSecretKeyFromData({ "secret-key": b64("real-key-123") }), "real-key-123"); + }); + await check("decodes 'ACP_SECRET_KEY' (legacy simulator)", () => { + assertEqual(resolveSecretKeyFromData({ ACP_SECRET_KEY: b64("legacy-key") }), "legacy-key"); + }); + await check("prefers 'secret-key' when both present", () => { + assertEqual( + resolveSecretKeyFromData({ + ACP_SECRET_KEY: b64("legacy"), + "secret-key": b64("preferred"), + }), + "preferred", + ); + }); + await check("sole-entry fallback for unknown key names", () => { + assertEqual(resolveSecretKeyFromData({ "some-custom-name": b64("solo-key") }), "solo-key"); + }); + await check("decodes multibyte utf-8", () => { + assertEqual(resolveSecretKeyFromData({ "secret-key": b64("clé-🔑-秘密") }), "clé-🔑-秘密"); + }); + await check("throws on empty data, listing looked-for keys", () => { + assertThrows(() => resolveSecretKeyFromData({}), ["secret-key", "ACP_SECRET_KEY", "no data entries"]); + }); + await check("throws on multiple unknown keys, listing keys present", () => { + assertThrows( + () => resolveSecretKeyFromData({ alpha: b64("a"), beta: b64("b") }), + ["alpha", "beta", "sole-entry"], + ); + }); + await check("throws descriptively on invalid base64", () => { + assertThrows(() => resolveSecretKeyFromData({ "secret-key": "!!!not-base64!!!" }), [ + "secret-key", + "base64", + ]); + }); + + console.log("ShimClient.acpUrl"); + await check("http -> ws with port", () => { + assertEqual( + new ShimClient("http://127.0.0.1:7080").acpUrl("ui-abc12"), + "ws://127.0.0.1:7080/api/agentruns/ui-abc12/acp", + ); + }); + await check("https -> wss, trailing slash + path prefix preserved", () => { + assertEqual( + new ShimClient("https://hub.example.com/shim/").acpUrl("run-1"), + "wss://hub.example.com/shim/api/agentruns/run-1/acp", + ); + }); + await check("run name is URL-encoded", () => { + assertEqual( + new ShimClient("http://localhost:7080").acpUrl("we ird"), + "ws://localhost:7080/api/agentruns/we%20ird/acp", + ); + }); + await check("rejects non-http(s) baseUrl", () => { + assertThrows(() => new ShimClient("ftp://nope"), ["http(s)"]); + }); + + console.log("isTerminalPhase"); + await check("Succeeded/Failed terminal; Pending/Running/undefined not", () => { + assertEqual(isTerminalPhase("Succeeded"), true, "Succeeded"); + assertEqual(isTerminalPhase("Failed"), true, "Failed"); + assertEqual(isTerminalPhase("Running"), false, "Running"); + assertEqual(isTerminalPhase("Pending"), false, "Pending"); + assertEqual(isTerminalPhase(undefined), false, "undefined"); + }); + + console.log("waitForRunning (fake RunApi, no network)"); + const makeApi = (sequence: AgentRun[]): RunApi => { + let i = 0; + return { + listAgents: () => Promise.reject(new Error("unused")), + getAgent: () => Promise.reject(new Error("unused")), + listApplications: () => Promise.reject(new Error("unused")), + listRuns: () => Promise.reject(new Error("unused")), + listPlaybooks: () => Promise.reject(new Error("unused")), + listPlaybookRuns: () => Promise.reject(new Error("unused")), + getPlaybookRun: () => Promise.reject(new Error("unused")), + createRun: () => Promise.reject(new Error("unused")), + createPlaybookRun: () => Promise.reject(new Error("unused")), + deleteRun: () => Promise.reject(new Error("unused")), + deletePlaybookRun: () => Promise.reject(new Error("unused")), + listImages: () => Promise.reject(new Error("unused")), + loadDefaults: () => Promise.reject(new Error("unused")), + getRun: () => Promise.resolve(sequence[Math.min(i++, sequence.length - 1)] as AgentRun), + }; + }; + const run = (status: AgentRun["status"]): AgentRun => ({ + metadata: { name: "r1" }, + spec: { agentRef: "migration-analyzer" }, + status, + }); + + await check("resolves once Running + sandboxName + secretKeyRef", async () => { + const phases: string[] = []; + const api = makeApi([ + run(undefined), + run({ phase: "Pending" }), + run({ phase: "Running" }), // Running but not connectable yet + run({ phase: "Running", sandboxName: "r1", secretKeyRef: { name: "r1-acp-key" } }), + ]); + const result = await waitForRunning(api, "r1", { + pollMs: 5, + timeoutMs: 2_000, + onPhase: (p) => phases.push(p), + }); + assertEqual(result.status?.sandboxName, "r1", "sandboxName"); + assertEqual(phases.join(","), "Pending,Pending,Running,Running", "observed phases"); + }); + await check("rejects on Failed with condition messages", async () => { + const api = makeApi([ + run({ + phase: "Failed", + conditions: [ + { type: "Ready", status: "False", message: "image pull failed" }, + { type: "Sandbox", status: "False", message: "sandbox gone" }, + ], + }), + ]); + await assertRejects(waitForRunning(api, "r1", { pollMs: 5, timeoutMs: 2_000 }), [ + "r1 failed", + "image pull failed", + "sandbox gone", + ]); + }); + await check("rejects on timeout with actionable message", async () => { + const api = makeApi([run({ phase: "Pending" })]); + await assertRejects(waitForRunning(api, "r1", { pollMs: 5, timeoutMs: 25 }), [ + "Timed out", + "phase=Pending", + "kubectl describe agentrun r1", + ]); + }); + await check("rejects promptly when signal aborts", async () => { + const api = makeApi([run({ phase: "Pending" })]); + const ac = new AbortController(); + const p = waitForRunning(api, "r1", { pollMs: 5_000, timeoutMs: 60_000, signal: ac.signal }); + setTimeout(() => ac.abort(new Error("user cancelled")), 10); + await assertRejects(p, ["user cancelled"]); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) { + throw new Error(`${failed} selfcheck(s) failed`); + } +} + +await main(); diff --git a/clients/packages/agentic-client/package-lock.json b/clients/packages/agentic-client/package-lock.json new file mode 100644 index 00000000..faf9d31f --- /dev/null +++ b/clients/packages/agentic-client/package-lock.json @@ -0,0 +1,548 @@ +{ + "name": "@konveyor/agentic-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@konveyor/agentic-client", + "version": "0.1.0", + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/clients/packages/agentic-client/package.json b/clients/packages/agentic-client/package.json new file mode 100644 index 00000000..9c352761 --- /dev/null +++ b/clients/packages/agentic-client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@konveyor/agentic-client", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Isomorphic (browser + node) client core for the konveyor agentic-controller: CRD contract types + helpers, ACP session over WebSocket (JSON-RPC 2.0), and the hub-shim HTTP transport. Zero runtime dependencies; no node builtins in src/.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "development": "./src/index.ts", + "default": "./src/index.ts" + }, + "./contract": { + "types": "./src/contract/index.ts", + "development": "./src/contract/index.ts", + "default": "./src/contract/index.ts" + }, + "./acp": { + "types": "./src/acp/index.ts", + "development": "./src/acp/index.ts", + "default": "./src/acp/index.ts" + }, + "./transport-shim": { + "types": "./src/transport-shim/index.ts", + "development": "./src/transport-shim/index.ts", + "default": "./src/transport-shim/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "selfcheck": "tsx dev/selfcheck.ts" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/clients/packages/agentic-client/src/acp/index.ts b/clients/packages/agentic-client/src/acp/index.ts new file mode 100644 index 00000000..f40820a5 --- /dev/null +++ b/clients/packages/agentic-client/src/acp/index.ts @@ -0,0 +1,625 @@ +/** + * ACP (Agent Client Protocol) session over WebSocket — browser-safe. + * + * Ported from editor-extensions-cluster-agent's ClusterAcpSession, minus + * vscode/winston/node deps. Instead of @agentclientprotocol/sdk (whose core + * client pulls in zod schema validation), this speaks the same wire protocol + * directly: JSON-RPC 2.0 text frames with id-correlated requests in BOTH + * directions, notifications, and error objects. Method names mirror the + * SDK's `acp.methods` exactly: + * + * client -> agent: initialize, session/new, session/load, session/prompt + * client -> agent (notification): session/cancel + * agent -> client (notification): session/update + * agent -> client (request): session/request_permission + * + * Browser constraints honored: + * - Default socket is `new globalThis.WebSocket(url)` with NO custom headers + * (browsers cannot set them; the hub-shim injects X-Secret-Key upstream). + * - Ping/pong liveness runs ONLY when the socket exposes `.ping` (node ws); + * browser sockets skip it. + * - A caller may inject a WebSocketFactory (e.g. node ws preconfigured with + * an X-Secret-Key header) — the factory owns header injection. + */ + +/** ACP protocol version this client speaks (mirrors the SDK's PROTOCOL_VERSION). */ +export const PROTOCOL_VERSION = 1; + +const HEARTBEAT_INTERVAL_MS = 10_000; +/** + * Where UI-initiated sessions root themselves: the migration-harness's clone + * directory (HARNESS_WORK_DIR default). Note the harness owns the RUN's + * session — it creates one at this cwd and drives the whole stage with a + * single composed prompt. A session created through this client is a + * SEPARATE, parallel conversation with the same agent; use session/load to + * observe/replay instead when the goal is to watch the run. + */ +const DEFAULT_CWD = "/workspace/repo"; + +// ------------------------------------------------------------------ types + +/** Pass-through of an ACP session/update payload (params.update). */ +export type SessionUpdate = { sessionUpdate: string; [k: string]: unknown }; + +/** + * A file modification from a ToolCallContent {type:"diff"} block + * (standard ACP shape: path + oldText/newText; oldText null = new file). + */ +export type ToolCallDiff = { + path: string; + oldText?: string | null; + newText: string; +}; + +/** An agent -> client session/request_permission ask. */ +export type PermissionRequest = { + sessionId: string; + toolCall?: { toolCallId?: string; title?: string; diffs?: ToolCallDiff[] }; + options: { optionId: string; name: string; kind: string }[]; +}; + +/** Shape of the session/request_permission response payload. */ +export interface PermissionOutcome { + outcome: { outcome: string; optionId?: string }; +} + +export interface AcpSessionCallbacks { + onUpdate?(u: SessionUpdate): void; + /** + * Human-in-the-loop approval. Return {outcome:{outcome:"selected", + * optionId}} or {outcome:{outcome:"cancelled"}}. When absent, permission + * asks are answered "cancelled" (never silently approved). + */ + onPermissionRequest?(r: PermissionRequest): Promise | PermissionOutcome; +} + +/** + * Returns a browser-compatible WebSocket instance for the url: native + * WebSocket in browsers; in node, callers may return a 'ws' instance + * preconfigured with headers (e.g. X-Secret-Key). The returned object only + * needs send/close plus addEventListener or on. + */ +export type WebSocketFactory = (url: string) => unknown; + +export type AcpLogger = Pick; + +export interface AcpConnectOptions { + url: string; + /** + * Informational only on the default (browser) path — browsers cannot set + * WebSocket headers, so the transport shim injects X-Secret-Key + * server-side. If you pass a webSocketFactory, the factory owns header + * injection (close over the key when building it). + */ + secretKey?: string; + webSocketFactory?: WebSocketFactory; + callbacks?: AcpSessionCallbacks; + logger?: AcpLogger; +} + +// ------------------------------------------------- WebSocket normalization + +/** Structural view over browser WebSocket and node 'ws'. */ +interface WsLike { + readyState?: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener?(type: string, listener: (...args: never[]) => void): void; + on?(event: string, listener: (...args: never[]) => void): void; + ping?(): void; + terminate?(): void; +} + +const WS_OPEN = 1; + +type AnyListener = (...args: unknown[]) => void; + +function subscribe(socket: WsLike, event: string, listener: AnyListener): void { + // Both browser WebSocket and node 'ws' implement addEventListener for + // open/message/close/error; 'pong' exists only on node ws via .on(). + if (event !== "pong" && typeof socket.addEventListener === "function") { + socket.addEventListener(event, listener as never); + } else if (typeof socket.on === "function") { + socket.on(event, listener as never); + } +} + +// ---------------------------------------------------------------- JSON-RPC + +interface PendingRequest { + method: string; + resolve: (value: unknown) => void; + reject: (reason: Error) => void; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Extracts {type:"diff"} blocks from a ToolCallUpdate.content array. */ +function parseToolCallDiffs(content: unknown): ToolCallDiff[] | undefined { + if (!Array.isArray(content)) return undefined; + const diffs: ToolCallDiff[] = []; + for (const block of content) { + if (!isRecord(block) || block.type !== "diff") continue; + if (typeof block.path !== "string" || typeof block.newText !== "string") continue; + diffs.push({ + path: block.path, + oldText: typeof block.oldText === "string" ? block.oldText : null, + newText: block.newText, + }); + } + return diffs.length > 0 ? diffs : undefined; +} + +interface InitializeResult { + protocolVersion?: number; + agentCapabilities?: { loadSession?: boolean; [k: string]: unknown }; + [k: string]: unknown; +} + +const NOOP_LOGGER: AcpLogger = { info() {}, warn() {}, error() {}, debug() {} }; + +// ------------------------------------------------------------- AcpSession + +/** + * A live, connected ACP session. Create with AcpSession.connect(); then + * newSession() or loadSession() before prompt(). + */ +export class AcpSession { + private readonly socket: WsLike; + private readonly callbacks: AcpSessionCallbacks; + private readonly logger: AcpLogger; + + private readonly pending = new Map(); + private nextId = 1; + + private _sessionId: string | null = null; + private _loadSessionSupported = false; + private promptActive = false; + private closed = false; + private explicitlyClosed = false; + + private pingTimer: ReturnType | null = null; + + private openPromise: Promise; + private resolveOpen: (() => void) | null = null; + private rejectOpen: ((err: Error) => void) | null = null; + private lastSocketError: Error | null = null; + + private constructor(socket: WsLike, callbacks: AcpSessionCallbacks, logger: AcpLogger) { + this.socket = socket; + this.callbacks = callbacks; + this.logger = logger; + + this.openPromise = new Promise((resolve, reject) => { + this.resolveOpen = resolve; + this.rejectOpen = reject; + }); + this.openPromise.catch(() => undefined); // avoid unhandled rejection + + subscribe(socket, "open", () => { + this.resolveOpen?.(); + this.resolveOpen = null; + this.rejectOpen = null; + }); + subscribe(socket, "message", (...args: unknown[]) => { + void this.handleMessageEvent(args); + }); + subscribe(socket, "error", (...args: unknown[]) => { + const err = extractError(args); + this.lastSocketError = err; + this.logger.warn(`AcpSession: socket error: ${err.message}`); + // Don't tear down here: a close event always follows an error. + this.rejectOpen?.(err); + this.rejectOpen = null; + this.resolveOpen = null; + }); + subscribe(socket, "close", (...args: unknown[]) => { + const { code, reason } = extractClose(args); + const detail = `code ${code ?? "?"}${reason ? `: ${reason}` : ""}`; + const err = + this.lastSocketError ?? new Error(`ACP connection closed (${detail})`); + this.rejectOpen?.(err); + this.rejectOpen = null; + this.resolveOpen = null; + this.teardown(err); + }); + } + + /** + * Opens the socket (default: `new globalThis.WebSocket(url)`, no custom + * headers) and performs ACP initialize; loadSessionSupported is read from + * the agent's advertised capabilities. + */ + static async connect(opts: AcpConnectOptions): Promise { + const logger = opts.logger ?? NOOP_LOGGER; + const raw: unknown = opts.webSocketFactory + ? opts.webSocketFactory(opts.url) + : new globalThis.WebSocket(opts.url); + if (!isRecord(raw) || typeof (raw as unknown as WsLike).send !== "function") { + throw new Error("AcpSession.connect: webSocketFactory must return a WebSocket-like object"); + } + const socket = raw as unknown as WsLike; + const session = new AcpSession(socket, opts.callbacks ?? {}, logger); + if (socket.readyState !== WS_OPEN) { + await session.openPromise; + } + session.startHeartbeat(); + logger.info(`AcpSession: connected ${opts.url}, initializing`); + const initialized = await session.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + session._loadSessionSupported = initialized?.agentCapabilities?.loadSession === true; + logger.info( + `AcpSession: initialized protocol v${initialized?.protocolVersion ?? "?"}, ` + + `loadSession=${session._loadSessionSupported}`, + ); + return session; + } + + get sessionId(): string | null { + return this._sessionId; + } + + get loadSessionSupported(): boolean { + return this._loadSessionSupported; + } + + isPromptActive(): boolean { + return this.promptActive; + } + + /** Start a fresh session in the sandbox workspace. */ + async newSession(): Promise { + const res = await this.request<{ sessionId: string }>("session/new", { + cwd: DEFAULT_CWD, + mcpServers: [], + }); + this._sessionId = res.sessionId; + return res.sessionId; + } + + /** Attach to an existing session; the agent replays history as updates. */ + async loadSession(id: string): Promise { + await this.request("session/load", { + sessionId: id, + cwd: DEFAULT_CWD, + mcpServers: [], + }); + this._sessionId = id; + } + + /** Send a prompt turn; resolves with the stop reason (e.g. "end_turn"). */ + async prompt(text: string): Promise { + if (!this._sessionId) { + throw new Error("AcpSession: no active session — call newSession() or loadSession() first"); + } + this.promptActive = true; + try { + // No client-side timeout: agent turns can be long; the promise settles + // on the agent's response or on connection close. + const res = await this.request<{ stopReason: string }>("session/prompt", { + sessionId: this._sessionId, + prompt: [{ type: "text", text }], + }); + return res.stopReason; + } finally { + this.promptActive = false; + } + } + + /** Cancel the in-flight turn (notification; the prompt settles separately). */ + async cancel(): Promise { + if (this._sessionId && !this.closed) { + this.notify("session/cancel", { sessionId: this._sessionId }); + } + } + + /** Close the connection; pending requests reject. Idempotent. */ + async close(): Promise { + this.explicitlyClosed = true; + try { + this.socket.close(1000, "client closed"); + } catch { + // already closed/never opened + } + this.teardown(new Error("ACP session closed by client")); + } + + /** + * Registers a callback for when the connection drops (socket close, pod + * restart, tunnel death). NOT fired by an explicit close(). Fires at most + * once, after registration, even if already closed. + */ + onClosed(cb: () => void): void { + if (this.closed) { + if (!this.explicitlyClosed) cb(); + return; + } + this.closedCallbacks.push(cb); + } + + private closedCallbacks: (() => void)[] = []; + + // ------------------------------------------------------------ internals + + private request(method: string, params: unknown): Promise { + if (this.closed) { + return Promise.reject(new Error(`AcpSession: cannot send ${method}, connection is closed`)); + } + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { + method, + resolve: resolve as (value: unknown) => void, + reject, + }); + try { + this.sendRaw({ jsonrpc: "2.0", id, method, params }); + } catch (err) { + this.pending.delete(id); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + private notify(method: string, params: unknown): void { + this.sendRaw({ jsonrpc: "2.0", method, params }); + } + + private sendRaw(message: Record): void { + this.socket.send(JSON.stringify(message)); + } + + private async handleMessageEvent(args: unknown[]): Promise { + if (this.closed) return; + const first = args[0]; + // Browser/ws addEventListener hand a MessageEvent ({data}); node ws .on + // hands (data, isBinary). + const raw = isRecord(first) && "data" in first ? (first as { data: unknown }).data : first; + const text = await toText(raw); + if (text === undefined) { + this.logger.warn("AcpSession: ignoring non-text WebSocket frame"); + return; + } + this.dispatch(text); + } + + private dispatch(text: string): void { + let msg: unknown; + try { + msg = JSON.parse(text); + } catch { + this.logger.warn(`AcpSession: ignoring malformed JSON frame: ${text.slice(0, 120)}`); + return; + } + if (!isRecord(msg) || msg.jsonrpc !== "2.0") { + this.logger.warn("AcpSession: ignoring non-JSON-RPC message"); + return; + } + const id = msg.id as number | string | null | undefined; + const method = msg.method; + if (typeof method === "string") { + if (id === undefined || id === null) { + this.handleNotification(method, msg.params); + } else { + void this.handleServerRequest(id, method, msg.params); + } + } else if (id !== undefined && id !== null) { + this.settle(id, msg); + } else { + this.logger.debug("AcpSession: ignoring message with neither method nor id"); + } + } + + private handleNotification(method: string, params: unknown): void { + if (method === "session/update") { + const update = isRecord(params) ? params.update : undefined; + if (isRecord(update) && typeof update.sessionUpdate === "string") { + try { + this.callbacks.onUpdate?.(update as SessionUpdate); + } catch (err) { + this.logger.error( + `AcpSession: onUpdate callback threw: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else { + this.logger.debug("AcpSession: session/update without a usable update payload"); + } + return; + } + this.logger.debug(`AcpSession: ignoring notification ${method}`); + } + + private async handleServerRequest( + id: number | string, + method: string, + params: unknown, + ): Promise { + if (method !== "session/request_permission") { + this.respondError(id, -32601, `Method not found: ${method}`); + return; + } + const p = isRecord(params) ? params : {}; + const toolCall = isRecord(p.toolCall) + ? { + toolCallId: typeof p.toolCall.toolCallId === "string" ? p.toolCall.toolCallId : undefined, + title: typeof p.toolCall.title === "string" ? p.toolCall.title : undefined, + diffs: parseToolCallDiffs(p.toolCall.content), + } + : undefined; + const options = Array.isArray(p.options) + ? p.options.filter(isRecord).map((o) => ({ + optionId: String(o.optionId ?? ""), + name: String(o.name ?? ""), + kind: String(o.kind ?? ""), + })) + : []; + const request: PermissionRequest = { + sessionId: typeof p.sessionId === "string" ? p.sessionId : "", + toolCall, + options, + }; + try { + const result: PermissionOutcome = this.callbacks.onPermissionRequest + ? await this.callbacks.onPermissionRequest(request) + : { outcome: { outcome: "cancelled" } }; + if (!this.closed) { + this.sendRaw({ jsonrpc: "2.0", id, result }); + } + } catch (err) { + this.logger.error( + `AcpSession: onPermissionRequest threw: ${err instanceof Error ? err.message : String(err)}`, + ); + this.respondError( + id, + -32603, + err instanceof Error ? err.message : "permission handler failed", + ); + } + } + + private respondError(id: number | string, code: number, message: string): void { + if (this.closed) return; + try { + this.sendRaw({ jsonrpc: "2.0", id, error: { code, message } }); + } catch { + // socket died mid-response; teardown handles pending state + } + } + + private settle(id: number | string, msg: Record): void { + const entry = this.pending.get(id); + if (!entry) { + this.logger.debug(`AcpSession: response for unknown request id ${String(id)}`); + return; + } + this.pending.delete(id); + const error = msg.error; + if (isRecord(error)) { + const code = typeof error.code === "number" ? error.code : undefined; + const message = typeof error.message === "string" ? error.message : "unknown error"; + const err = new Error(`ACP ${entry.method} failed: ${message}${code !== undefined ? ` (code ${code})` : ""}`); + Object.assign(err, { code, data: error.data }); + entry.reject(err); + } else { + entry.resolve(msg.result); + } + } + + /** + * Ping/pong liveness — node 'ws' only (browser sockets have no .ping; + * half-open detection is left to the browser/network stack). A missed + * pong across one interval means the far side is gone: terminate so + * 'close' fires and reconnect logic can take over. + */ + private startHeartbeat(): void { + if (typeof this.socket.ping !== "function" || typeof this.socket.on !== "function") { + return; + } + let alive = true; + subscribe(this.socket, "pong", () => { + alive = true; + }); + this.pingTimer = setInterval(() => { + if (this.closed) { + this.stopHeartbeat(); + return; + } + if (!alive) { + this.logger.warn("AcpSession: missed pong, terminating dead connection"); + if (typeof this.socket.terminate === "function") { + this.socket.terminate(); + } else { + this.socket.close(); + } + return; + } + alive = false; + this.socket.ping?.(); + }, HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + private teardown(err: Error): void { + if (this.closed) return; + this.closed = true; + this.stopHeartbeat(); + for (const [, entry] of this.pending) { + entry.reject( + new Error(`ACP ${entry.method} did not complete: ${err.message}`), + ); + } + this.pending.clear(); + if (!this.explicitlyClosed) { + for (const cb of this.closedCallbacks.splice(0)) { + try { + cb(); + } catch (cbErr) { + this.logger.error( + `AcpSession: onClosed callback threw: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`, + ); + } + } + } + this.closedCallbacks = []; + } +} + +// ---------------------------------------------------------------- helpers + +async function toText(raw: unknown): Promise { + if (typeof raw === "string") return raw; + if (raw instanceof ArrayBuffer) return new TextDecoder().decode(raw); + if (ArrayBuffer.isView(raw)) return new TextDecoder().decode(raw); + if (typeof Blob !== "undefined" && raw instanceof Blob) return raw.text(); + // node ws can deliver fragmented messages as an array of buffers + if (Array.isArray(raw) && raw.length > 0 && raw.every((c) => ArrayBuffer.isView(c))) { + const decoder = new TextDecoder(); + return raw.map((c) => decoder.decode(c as ArrayBufferView, { stream: true })).join("") + decoder.decode(); + } + return undefined; +} + +function extractError(args: unknown[]): Error { + const first = args[0]; + if (first instanceof Error) return first; + if (isRecord(first)) { + // browser ErrorEvent / ws ErrorEvent both may carry .message or .error + const inner = first.error; + if (inner instanceof Error) return inner; + if (typeof first.message === "string" && first.message) return new Error(first.message); + } + return new Error("WebSocket error"); +} + +function extractClose(args: unknown[]): { code?: number; reason?: string } { + const first = args[0]; + if (typeof first === "number") { + // node ws .on("close", (code, reason: Buffer) => ...) + const second = args[1]; + const reason = + typeof second === "string" + ? second + : ArrayBuffer.isView(second) + ? new TextDecoder().decode(second) + : undefined; + return { code: first, reason }; + } + if (isRecord(first)) { + // CloseEvent (browser + ws addEventListener) + return { + code: typeof first.code === "number" ? first.code : undefined, + reason: typeof first.reason === "string" ? first.reason : undefined, + }; + } + return {}; +} diff --git a/clients/packages/agentic-client/src/contract/index.ts b/clients/packages/agentic-client/src/contract/index.ts new file mode 100644 index 00000000..c0322e50 --- /dev/null +++ b/clients/packages/agentic-client/src/contract/index.ts @@ -0,0 +1,829 @@ +/** + * Contract types + helpers for the konveyor.io/v1alpha1 AgentRun surface. + * + * Source of truth: github.com/konveyor/agentic-controller api/v1alpha1/*.go + * (the REAL controller, PR #4 era). Everything here is browser-safe: no + * node builtins, no kube client — transports live elsewhere (see + * ../transport-shim for the hub-shim HTTP transport). + * + * Verified controller facts encoded here: + * - Sandbox pod name == status.sandboxName EXACTLY (real controller: + * sandboxName == run name). Never string-munge run names. + * - ACP key Secret is named via status.secretKeyRef.name; the data key is + * "secret-key" (real controller) or "ACP_SECRET_KEY" (legacy simulator). + * - ACP server: pod port 4000, path /acp, X-Secret-Key header auth. + * - AgentRun spec is IMMUTABLE after create — delete+recreate, never patch. + * - The migration-harness (PR #53 era) is Konveyor-aware: it pulls the + * application's repo, decrypted git identity, and analysis from the Hub + * itself, keyed by env vars on the run spec (see RUN_ENV). It hard-requires + * RUN_ENV.TARGET_BRANCH and a "primary"-role model selection, and fails + * fatally when the Agent mounts zero skills. + */ + +// ---------------------------------------------------------------- k8s meta + +export interface ObjectMeta { + name?: string; + generateName?: string; + namespace?: string; + labels?: Record; + annotations?: Record; + uid?: string; + resourceVersion?: string; + creationTimestamp?: string; +} + +export interface Condition { + type: string; + status: "True" | "False" | "Unknown"; + reason?: string; + message?: string; + lastTransitionTime?: string; + observedGeneration?: number; +} + +/** Browser-safe mirror of corev1.EnvVar (valueFrom left opaque on purpose). */ +export interface EnvVar { + name: string; + value?: string; + valueFrom?: unknown; +} + +/** Browser-safe mirror of corev1.EnvFromSource. */ +export interface EnvFromSource { + configMapRef?: { name: string; optional?: boolean }; + secretRef?: { name: string; optional?: boolean }; + prefix?: string; +} + +// ---------------------------------------------------------------- AgentRun + +export type AgentRunPhase = "Pending" | "Running" | "Succeeded" | "Failed"; + +export interface AgentRunParam { + /** Matches an Agent param declaration; injected as KONVEYOR_PARAM_. */ + name: string; + value: string; +} + +export interface AgentRunModelSelection { + role: string; + provider: string; + model: string; +} + +export interface AgentRunSpec { + /** Name of the Agent CR to execute. Immutable (whole-spec CEL rule). */ + agentRef: string; + params?: AgentRunParam[]; + /** Task-specific instructions, composed with the Agent's standing prompt. */ + instructions?: string; + models?: AgentRunModelSelection[]; + /** + * Pod env passthrough. The Konveyor platform rides the harness's Hub + * coordinates and target branch here (RUN_ENV) — never as spec fields, + * keeping the CRD platform-neutral. + */ + env?: EnvVar[]; + /** Pod envFrom passthrough (LLM-provider credential Secrets etc.). */ + envFrom?: EnvFromSource[]; +} + +export interface AgentRunStatus { + phase?: AgentRunPhase; + observedGeneration?: number; + /** + * Name of the Sandbox CR created for this run. The backing pod has this + * EXACT name — resolve the pod by name (works against every controller + * build; the konveyor.io/agentrun pod label exists only since #34 and is + * a fallback, not the primary mechanism). + */ + sandboxName?: string; + startTime?: string; + completionTime?: string; + /** Wall-clock duration of the run in seconds. */ + duration?: number; + /** Secret holding the ACP auth key (X-Secret-Key header value). */ + secretKeyRef?: { name: string }; + conditions?: Condition[]; +} + +export interface AgentRun { + apiVersion?: string; + kind?: string; + metadata: ObjectMeta; + spec: AgentRunSpec; + status?: AgentRunStatus; +} + +// ------------------------------------------------------------- playbooks + +/** Label keys the playbook-run controller stamps on stage AgentRuns. */ +export const PLAYBOOK_RUN_LABEL = "konveyor.io/agentplaybookrun"; +export const PLAYBOOK_STAGE_LABEL = "konveyor.io/stage"; + +/** One stage of an AgentPlaybook: an Agent plus stage instructions. */ +export interface AgentPlaybookStage { + /** + * Stage name. Used in labels on the stage's AgentRun, so the CRD requires + * a label-safe value: lowercase alphanumerics/hyphens/dots, max 63 chars + * (STAGE_NAME_PATTERN), unique within the playbook. + */ + name: string; + agentRef: string; + instructions?: string; +} + +/** CRD pattern for AgentPlaybookStage.name (label-safe, ≤63 chars). */ +export const STAGE_NAME_PATTERN = /^[a-z0-9]([a-z0-9\-.]*[a-z0-9])?$/; + +export interface AgentPlaybookSpec { + /** + * High-level guide injected into every stage as the + * KONVEYOR_PLAYBOOK_INSTRUCTIONS env var, composed into the prompt + * between the Agent's standing prompt and the mounted skills. + */ + guide?: string; + /** + * Ordered stages; executed sequentially on a shared target branch + * (CRD: min 1). Stages chain through committed artifacts on that branch + * (plan commits PLAN.md, execute reads it) — one stage's chat output is + * never fed into the next stage's prompt. + */ + stages: AgentPlaybookStage[]; +} + +/** An AgentPlaybook CR — a reusable template; creating one executes nothing. */ +export interface AgentPlaybook { + apiVersion?: string; + kind?: string; + metadata: ObjectMeta; + spec: AgentPlaybookSpec; + status?: { observedGeneration?: number; conditions?: Condition[] }; +} + +/** Per-stage status of an AgentPlaybookRun (status.stages[]). */ +export interface AgentPlaybookRunStage { + name: string; + /** Reuses the AgentRun phase enum. */ + phase: AgentRunPhase; + /** + * Name of the stage's AgentRun once created. Always read this (or the + * labels) rather than recomputing "-" — names are + * hash-truncated past 63 chars. + */ + agentRunName?: string; +} + +export interface AgentPlaybookRunStatus { + phase?: AgentRunPhase; + observedGeneration?: number; + /** + * Stage currently executing. Cleared only when the run Succeeds — on a + * Failed run it stays set to the stage that failed. Use phase (or + * completionTime) as the terminal test, never this field. + */ + currentStage?: string; + stages?: AgentPlaybookRunStage[]; + startTime?: string; + completionTime?: string; + /** + * NOTE: unlike AgentRunStatus there is no duration field — compute it + * from startTime/completionTime. Ready=False reason=StageRunning is the + * NORMAL healthy state while executing, not an error. + */ + conditions?: Condition[]; +} + +export interface AgentPlaybookRun { + apiVersion: string; + kind: "AgentPlaybookRun"; + metadata: ObjectMeta; + spec: { + playbookRef: string; + params?: { name: string; value: string }[]; + models?: { role: string; provider: string; model: string }[]; + /** Forwarded verbatim to every stage's AgentRun (Hub coords ride here). */ + env?: EnvVar[]; + /** Forwarded verbatim to every stage's AgentRun. */ + envFrom?: EnvFromSource[]; + }; + status?: AgentPlaybookRunStatus; +} + +// ------------------------------------------------------------------- Agent + +export type AgentParamType = "string" | "number" | "boolean"; + +export interface AgentParam { + name: string; + type?: AgentParamType; + description?: string; + default?: string; + required?: boolean; +} + +export interface AgentResourceSpec { + /** Container image carrying the agent runtime (ACP server on :4000/acp). */ + image: string; + /** Standing instructions, composed with AgentRun.spec.instructions. */ + prompt?: string; + params?: AgentParam[]; + providers?: { ref: string }[]; + /** + * SkillCard refs resolved to OCI image volumes mounted at + * /opt/skills/. The migration-harness fails fatally when zero + * skills are mounted — treat an empty skill set as a misconfigured + * migration agent (only fixtures like the mock harness run without). + */ + skillCards?: { ref: string }[]; + /** SkillCollection refs; members mount alongside skillCards, deduped by name. */ + skillCollections?: { ref: string }[]; +} + +/** An Agent CR ("AgentResource" to avoid clashing with UI "agent" concepts). */ +export interface AgentResource { + apiVersion?: string; + kind?: string; + metadata: ObjectMeta; + spec: AgentResourceSpec; + status?: { observedGeneration?: number; conditions?: Condition[] }; +} + +/** CRD pattern for AgentParam.name (uppercased into KONVEYOR_PARAM_). */ +export const PARAM_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +// -------------------------------------------------------------- SkillCard + +/** + * "skill" is on-demand (name/description loaded at startup, content on + * invocation); "rule" is always-loaded into every turn's context. + */ +export type SkillCardType = "skill" | "rule"; + +/** + * SkillCard content source: exactly ONE of image | source | inline (CRD CEL + * rule). Image-ref cards are the only kind the controller resolves today — + * git-source and inline cards reconcile to NotReady ("Phase 3"), so + * authoring UIs should create image cards only. + */ +export interface SkillCardSpec { + /** OCI image reference for a pre-built skill artifact. */ + image?: string; + /** Git URL of a skill directory (controller support: Phase 3, NotReady). */ + source?: string; + /** Raw SKILL.md markdown (controller support: Phase 3, NotReady). */ + inline?: string; + displayName?: string; + version?: string; + description?: string; + /** Defaults to "skill" server-side. */ + type?: SkillCardType; + /** Categorization tags (CRD: a set — unique values). */ + tags?: string[]; +} + +export interface SkillCard { + apiVersion?: string; + kind?: string; + metadata: ObjectMeta; + spec: SkillCardSpec; + status?: { + observedGeneration?: number; + /** OCI ref the controller resolved; what actually mounts at /opt/skills. */ + resolvedImage?: string; + conditions?: Condition[]; + }; +} + +// -------------------------------------------------------- SkillCollection + +/** + * One member of a SkillCollection: exactly ONE of skillCardRef | image | + * source (CRD CEL rule). Members with a direct image need no SkillCard CR; + * git-source members spawn child SkillCards that are NotReady today. + */ +export interface SkillCollectionSkillRef { + /** Local name for this skill within the collection (unique, min 1 char). */ + name: string; + skillCardRef?: string; + image?: string; + source?: string; +} + +export interface SkillCollectionSpec { + version?: string; + /** Ordered members (CRD: min 1). */ + skills: SkillCollectionSkillRef[]; +} + +export interface SkillCollection { + apiVersion?: string; + kind?: string; + metadata: ObjectMeta; + spec: SkillCollectionSpec; + status?: { observedGeneration?: number; conditions?: Condition[] }; +} + +// ------------------------------------------------------------ LLMProvider + +export interface LLMProviderModel { + name: string; + contextWindow: number; + /** e.g. "primary" — the tier the platform's default model policy prefers. */ + tier?: string; +} + +/** + * Browser-safe mirror of the LLMProvider CR. NOTE: the migration-harness + * maps the CR NAME to a goose provider id verbatim (lowercased, "-" → "_"), + * so providers must be named like goose provider ids (e.g. "aws-bedrock"). + */ +export interface LLMProvider { + apiVersion?: string; + kind?: string; + metadata: ObjectMeta; + spec: { + endpoint: string; + /** key omitted = keyless: the whole Secret reaches the sandbox (SigV4). */ + credentialRef: { secretName: string; key?: string }; + models: LLMProviderModel[]; + }; + status?: { + observedGeneration?: number; + connectionVerified?: boolean; + discoveredModels?: string[]; + conditions?: Condition[]; + }; +} + +// ------------------------------------------------------------ ACP endpoint + +/** + * Secret data keys the ACP key may live under, tried in order: the real + * agentic-controller writes "secret-key"; the legacy dev simulator wrote + * "ACP_SECRET_KEY". If neither is present but the secret holds exactly one + * entry, that sole entry is used. + */ +export const SECRET_DATA_KEYS = ["secret-key", "ACP_SECRET_KEY"] as const; + +/** Port the sandbox pod's ACP server listens on. */ +export const ACP_PORT = 4000; + +/** HTTP/WebSocket path of the ACP server on the pod. */ +export const ACP_PATH = "/acp"; + +/** + * Resolves the ACP secret key from a k8s Secret's `.data` map (values are + * base64-encoded, as returned by the apiserver). Tries SECRET_DATA_KEYS in + * order, then falls back to the sole entry if exactly one key exists. + * Returns the DECODED utf-8 key (the X-Secret-Key header value). + */ +export function resolveSecretKeyFromData(data: Record): string { + const present = Object.keys(data); + for (const key of SECRET_DATA_KEYS) { + const value = data[key]; + if (value !== undefined) { + return decodeBase64Utf8(value, key); + } + } + if (present.length === 1) { + const sole = present[0] as string; + return decodeBase64Utf8(data[sole] as string, sole); + } + throw new Error( + `No ACP secret key found in secret data: looked for ${SECRET_DATA_KEYS.join(", ")}, ` + + (present.length === 0 + ? "but the secret has no data entries." + : `and the secret has ${present.length} entries (${present.join(", ")}) so the ` + + "sole-entry fallback does not apply.") + + " Expected the AgentRun's -acp-key secret.", + ); +} + +function decodeBase64Utf8(b64: string, keyName: string): string { + let binary: string; + try { + binary = atob(b64.replace(/\s+/g, "")); + } catch (err) { + throw new Error( + `Secret data key "${keyName}" is not valid base64: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +} + +// --------------------------------------------------- harness run injection + +/** + * Env var names the Konveyor platform injects on run specs (spec.env) so the + * Konveyor-aware harness can pull everything else itself: it fetches the + * application's repo URL, decrypted git identity, and analysis from the Hub, + * clones, strips credentials before the agent starts, and is the only thing + * that pushes. These ride the CRD's generic env passthrough — they are NOT + * spec fields, keeping the controller platform-neutral. + */ +export const RUN_ENV = { + /** Hub REST base the harness dials (in-cluster service DNS, usually). */ + HUB_BASE_URL: "HUB_BASE_URL", + /** + * Bearer token for the harness's Hub API calls (application, git + * identity, analysis). Optional only against an UNAUTHENTICATED Hub — + * repo visibility is irrelevant, since even a public-repo run resolves + * the application through the Hub first. Platforms should deliver it as + * a Secret-backed valueFrom env var, never a plaintext value in the CR. + */ + HUB_TOKEN: "HUB_TOKEN", + /** Numeric Hub application id (the harness rejects non-numeric ids). */ + APP_ID: "APP_ID", + /** + * Branch the harness checks out and pushes to. Required, and must differ + * from the application's source branch. Nothing in the system generates + * it — the caller mints it (defaultTargetBranch). If origin/ + * already exists the harness continues it, which is how playbook stages + * chain work on one branch. + */ + TARGET_BRANCH: "TARGET_BRANCH", +} as const; + +/** Prefix for platform-minted migration branches. */ +export const TARGET_BRANCH_PREFIX = "konveyor/migration-"; + +/** + * Default migration branch name, unique per second — the same shape the + * upstream harness testbed mints. Callers may override with anything that + * differs from the application's source branch. + */ +export function defaultTargetBranch(now: Date = new Date()): string { + return `${TARGET_BRANCH_PREFIX}${Math.floor(now.getTime() / 1000)}`; +} + +/** + * Why a string is unusable as the harness's target branch, or undefined if + * it looks fine. Shared by the shim's request validation and the create + * forms so both sides reject the same shapes (git refname rules: control + * chars and refname specials, "..", "@{", leading "-", dot/".lock"/empty + * path components). + */ +export function invalidTargetBranchReason(branch: string): string | undefined { + const b = branch.trim(); + if (!b) return "required"; + const badComponent = (c: string) => c === "" || c.startsWith(".") || c.endsWith(".lock"); + if ( + // eslint-disable-next-line no-control-regex -- git forbids control chars in refnames + /[\x00-\x20~^:?*[\\\x7f]/.test(b) || + b.includes("..") || + b.includes("@{") || + b === "@" || + b.startsWith("-") || + b.endsWith(".") || + b.endsWith("/") || + b.split("/").some(badComponent) + ) { + return "not a valid git branch name"; + } + return undefined; +} + +// ------------------------------------------------- platform-resolved params + +/** + * Label marking Agents the Konveyor platform (Hub/UI) knows how to drive. + * Platform agent lists filter on this; unlabeled Agents stay invisible to + * Konveyor UIs without affecting other consumers of the generic CRD. + */ +export const MANAGED_LABEL = "konveyor.io/managed"; + +/** + * Agent annotation mapping param name -> source identifier, e.g. + * {"repository": "konveyor.io/application-repository-url"}. A param with a + * source is resolved by the platform at run creation; params without one are + * supplied by the caller. Source identifiers are namespaced strings, NOT a + * CRD enum — consumers that do not recognize a value MUST fail open and + * treat the param as caller-supplied. + * + * @deprecated RETIRED for the Konveyor path (2026-07): the Konveyor-aware + * harness pulls repo/branch/credentials from the Hub itself, keyed by + * RUN_ENV injection — the platform no longer resolves these annotations at + * create time. The vocabulary remains documented for callers that resolve + * values themselves (issue-22 open question 1). + */ +export const PARAM_SOURCES_ANNOTATION = "konveyor.io/param-sources"; + +/** + * Agent annotation mapping credential name -> source identifier, e.g. + * {"git": "konveyor.io/application-identity"}. Same contract as param + * sources but resolves to a Secret the platform mounts via + * AgentRun.spec.envFrom instead of a string param value. + * + * @deprecated RETIRED for the Konveyor path (2026-07) — see + * PARAM_SOURCES_ANNOTATION. The harness fetches the decrypted identity from + * the Hub in-pod and withholds it from the agent; no Secret bridge exists. + */ +export const CREDENTIAL_SOURCES_ANNOTATION = "konveyor.io/credential-sources"; + +/** Well-known source identifiers the prototype platform resolves. */ +export const SOURCE_APPLICATION_REPOSITORY_URL = "konveyor.io/application-repository-url"; +export const SOURCE_APPLICATION_REPOSITORY_BRANCH = "konveyor.io/application-repository-branch"; +export const SOURCE_APPLICATION_IDENTITY = "konveyor.io/application-identity"; + +/** + * Parses an Agent's param-sources (or credential-sources) annotation into a + * name -> source map. Returns {} for a missing, malformed, or non-object + * annotation — bad metadata must never break run creation (fail open). + */ +export function parseSourcesAnnotation( + agent: Pick | undefined, + annotation: string = PARAM_SOURCES_ANNOTATION, +): Record { + const raw = agent?.metadata?.annotations?.[annotation]; + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [name, source] of Object.entries(parsed)) { + if (typeof source === "string" && source.trim() !== "") out[name] = source; + } + return out; + } catch { + return {}; + } +} + +/** + * A Konveyor application as the platform's application inventory exposes it. + * Backed by real Konveyor Hub Application records (repository + linked + * identities); the hub-shim reads Hub over HUB_URL and maps them here. + */ +export interface Application { + id: string; + name: string; + repository?: { url: string; branch?: string }; + /** + * The application's source-control credential as Hub holds it — a named + * Identity in Hub's vault. Present when the app has a `source` identity. + * Display-only: the harness fetches the DECRYPTED identity from the Hub + * itself (in-pod, withheld from the agent) — no Secret ever materializes + * platform-side. + */ + identity?: { name: string }; +} + +// ---------------------------------------------------------- image catalog + +/** + * One entry in the platform's agent-image catalog: the upstream image + * hierarchy (agent-base + per-language derivatives) plus whatever a site + * adds. Backed by a managed ConfigMap (IMAGE_CATALOG_CONFIGMAP) so the + * catalog is cluster data, not client code — an AgentImage CRD is the + * upstream escalation if the ConfigMap proves insufficient. + */ +export interface AgentImage { + /** Full image ref an Agent.spec.image can use verbatim. */ + image: string; + displayName: string; + /** Language toolchain the image bakes in; absent on the base image. */ + language?: string; + /** What the image adds over its base. */ + notes?: string; + /** Catalog-internal ref to the image this one derives from. */ + base?: string; +} + +/** ConfigMap (in the shim's namespace) holding the agent-image catalog. */ +export const IMAGE_CATALOG_CONFIGMAP = "agent-image-catalog"; + +/** Key inside the catalog ConfigMap whose value is a JSON AgentImage[]. */ +export const IMAGE_CATALOG_KEY = "catalog.json"; + +// --------------------------------------------------------- seeded defaults + +/** Outcome for one resource of a RunApi.loadDefaults seeding pass. */ +export interface SeedResult { + kind: string; + name: string; + /** "exists" means the resource was already there — seeding never updates. */ + status: "created" | "exists"; +} + +// ----------------------------------------------------------------- RunApi + +/** Input for RunApi.createRun — params as a plain map, mapped by the transport. */ +export interface CreateRunInput { + agentRef: string; + params?: Record; + instructions?: string; + /** + * Hub application this run works on. The platform injects the Hub + * coordinates + APP_ID (+ TARGET_BRANCH) into spec.env; the harness pulls + * repo, credentials, and analysis from the Hub itself. Must be a NUMERIC + * Hub id — the harness rejects anything else. Omit only for agents that + * run without an application (e.g. the mock fixture). + */ + applicationRef?: string; + /** + * Branch the harness creates (or continues) and pushes migration work to. + * Only meaningful with applicationRef. Defaults to defaultTargetBranch(); + * must differ from the application's source branch. + */ + targetBranch?: string; + /** + * Explicit "primary"-role model selection. Omitted: the platform's default + * policy picks the agent's FIRST declared provider and that provider's + * primary-tier model (else its first). Supplied: the provider must be + * among the agent's declared providers and the model declared on the + * LLMProvider CR — the shim rejects anything else (400). + */ + model?: { provider: string; model: string }; +} + +/** + * Input for RunApi.createPlaybookRun. Deliberately NO instructions field: + * AgentPlaybookRun.spec has none — stage instructions come from the + * playbook. Params forward wholesale to every stage, and the injected env + * (Hub coords + TARGET_BRANCH) is forwarded verbatim to every stage's + * AgentRun — one shared branch is how stages chain work. + */ +export interface CreatePlaybookRunInput { + playbookRef: string; + params?: Record; + /** Same semantics as CreateRunInput.applicationRef, applied to every stage. */ + applicationRef?: string; + /** Same semantics as CreateRunInput.targetBranch — one branch, all stages. */ + targetBranch?: string; + /** + * Same semantics as CreateRunInput.model, applied to EVERY stage (there + * are no per-stage overrides): the provider must be declared by every + * stage Agent and the model by the LLMProvider CR, else 400. + */ + model?: { provider: string; model: string }; +} + +/** + * Transport-agnostic API over Agents + AgentRuns. Implemented today by + * ShimClient (hub-shim HTTP); a future Konveyor Hub proxy exposes the same + * shape. NOTE: AgentRun spec is immutable — there is deliberately no update. + */ +export interface RunApi { + listAgents(): Promise; + /** Get one Agent by name — unfiltered, so stage agents without the + * managed label are still introspectable for their declared params. */ + getAgent(name: string): Promise; + /** Platform application inventory (for resolving sourced params). */ + listApplications(): Promise; + listRuns(): Promise; + listPlaybooks(): Promise; + listPlaybookRuns(): Promise; + getPlaybookRun(name: string): Promise; + createRun(input: CreateRunInput): Promise; + /** Spec is whole-spec immutable — delete+recreate, never update. */ + createPlaybookRun(input: CreatePlaybookRunInput): Promise; + getRun(name: string): Promise; + deleteRun(name: string): Promise; + /** Cascades: stage AgentRuns are owner-referenced and GC'd. */ + deletePlaybookRun(name: string): Promise; + /** The platform's agent-image catalog (source for image pickers). */ + listImages(): Promise; + /** + * Seeds the default managed resource set (provider, stage agents, skill + * cards, playbooks, image catalog). Idempotent: existing resources are + * left untouched and reported as "exists". + */ + loadDefaults(): Promise; +} + +// -------------------------------------------------------------- CatalogApi + +/** + * Management surface over the CRs a Konveyor UI authors: Agents, SkillCards, + * SkillCollections, AgentPlaybooks (+ read-only LLMProviders for pickers). + * Implemented by ShimClient; the write routes are the R1 proposal for the + * Konveyor Hub, exactly as the run routes were (issue-22 contract). + * + * Semantics (thin k8s passthrough — no domain logic, per the issue-22 + * placement decision): + * - create(name, spec): named create; the platform stamps + * `konveyor.io/managed=true` (MANAGED_LABEL). 409 when the name exists. + * - update(name, spec): spec replacement on the existing object; metadata is + * preserved and the managed label stamped (editing adopts an unlabeled + * resource into the platform). 404 when absent, 409 on write conflict. + * - delete(name): 204 | 404. No reference checking — the apiserver and + * controllers own integrity; UIs should surface references before + * offering delete. + * - CRD schema/CEL violations surface as 4xx with the apiserver's message — + * the platform does not duplicate schema validation. + * - Lists are filtered to `konveyor.io/managed=true`; get-by-name is never + * filtered. LLMProvider lists are NOT filtered: providers are + * cluster-admin-owned and this surface never creates them. + */ +export interface CatalogApi { + listProviders(): Promise; + getProvider(name: string): Promise; + + listSkillCards(): Promise; + getSkillCard(name: string): Promise; + createSkillCard(name: string, spec: SkillCardSpec): Promise; + updateSkillCard(name: string, spec: SkillCardSpec): Promise; + deleteSkillCard(name: string): Promise; + + listSkillCollections(): Promise; + getSkillCollection(name: string): Promise; + createSkillCollection(name: string, spec: SkillCollectionSpec): Promise; + updateSkillCollection(name: string, spec: SkillCollectionSpec): Promise; + deleteSkillCollection(name: string): Promise; + + createAgent(name: string, spec: AgentResourceSpec): Promise; + updateAgent(name: string, spec: AgentResourceSpec): Promise; + deleteAgent(name: string): Promise; + + getPlaybook(name: string): Promise; + createPlaybook(name: string, spec: AgentPlaybookSpec): Promise; + updatePlaybook(name: string, spec: AgentPlaybookSpec): Promise; + deletePlaybook(name: string): Promise; +} + +/** + * K8s object-name shape (DNS-1123 subdomain) for named creates. The + * apiserver enforces this anyway; forms use it for early feedback. + */ +export const RESOURCE_NAME_PATTERN = /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/; +export const RESOURCE_NAME_MAX = 253; + +// ---------------------------------------------------------------- waiting + +/** True when the run can no longer make progress (Succeeded or Failed). */ +export function isTerminalPhase(p?: string): boolean { + return p === "Succeeded" || p === "Failed"; +} + +export interface WaitForRunningOptions { + /** Overall deadline. Default 120_000 ms. */ + timeoutMs?: number; + /** Poll interval. Default 1_000 ms. */ + pollMs?: number; + signal?: AbortSignal; + /** Progress callback, invoked once per poll with the observed phase. */ + onPhase?: (phase: string, elapsedMs: number) => void; +} + +/** + * Polls the run until it is connectable: phase == Running AND + * status.sandboxName AND status.secretKeyRef are set. Rejects on phase == + * Failed (with condition messages), on timeout (with an actionable + * message), or when opts.signal aborts. + */ +export async function waitForRunning( + api: RunApi, + name: string, + opts?: WaitForRunningOptions, +): Promise { + const timeoutMs = opts?.timeoutMs ?? 120_000; + const pollMs = opts?.pollMs ?? 1_000; + const started = Date.now(); + for (;;) { + opts?.signal?.throwIfAborted(); + const run = await api.getRun(name); + const phase = run.status?.phase ?? "Pending"; + const elapsed = Date.now() - started; + opts?.onPhase?.(phase, elapsed); + if (phase === "Failed") { + const detail = (run.status?.conditions ?? []) + .map((c) => c.message) + .filter(Boolean) + .join("; "); + throw new Error(`AgentRun ${name} failed${detail ? `: ${detail}` : ""}`); + } + if (phase === "Running" && run.status?.sandboxName && run.status?.secretKeyRef?.name) { + return run; + } + if (Date.now() - started >= timeoutMs) { + throw new Error( + `Timed out after ${timeoutMs}ms waiting for AgentRun ${name} to reach Running with a ` + + `sandbox and ACP key (last phase=${phase}, sandboxName=${run.status?.sandboxName ?? "unset"}, ` + + `secretKeyRef=${run.status?.secretKeyRef?.name ?? "unset"}). The sandbox may still be ` + + `pulling its image or the controller may not be reconciling — check ` + + `'kubectl describe agentrun ${name}' and the agentic-controller logs.`, + ); + } + await sleep(pollMs, opts?.signal); + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/clients/packages/agentic-client/src/index.ts b/clients/packages/agentic-client/src/index.ts new file mode 100644 index 00000000..06f0b05c --- /dev/null +++ b/clients/packages/agentic-client/src/index.ts @@ -0,0 +1,12 @@ +/** + * @konveyor/agentic-client — isomorphic (browser + node) client core for + * the konveyor agentic-controller. + * + * Subpath entry points (also usable directly): + * @konveyor/agentic-client/contract CRD types + helpers + * @konveyor/agentic-client/acp ACP session over WebSocket + * @konveyor/agentic-client/transport-shim hub-shim HTTP transport + */ +export * from "./contract/index.js"; +export * from "./acp/index.js"; +export * from "./transport-shim/index.js"; diff --git a/clients/packages/agentic-client/src/transport-shim/index.ts b/clients/packages/agentic-client/src/transport-shim/index.ts new file mode 100644 index 00000000..4621d2b7 --- /dev/null +++ b/clients/packages/agentic-client/src/transport-shim/index.ts @@ -0,0 +1,283 @@ +/** + * ShimClient — RunApi over the hub-shim HTTP API (fetch-based, no node + * builtins; works in browsers and node >= 18). + * + * SHIM HTTP API v1 (the future Konveyor Hub proxy is expected to expose the + * same shape). Full route table + semantics: docs/adr/0004. + * GET /api/applications -> Application[] (platform inventory) + * GET /api/agents -> AgentResource[] (konveyor.io/managed=true) + * GET /api/agentruns -> AgentRun[] + * POST /api/agentruns -> 201 AgentRun (applicationRef resolves + * sourced params/credentials, ADR 0005) + * GET /api/agentruns/:name -> AgentRun | 404 + * DELETE /api/agentruns/:name -> 204 + * WS /api/agentruns/:name/acp -> ACP tunnel to the sandbox pod + * (the shim injects X-Secret-Key) + * GET /api/agentplaybooks -> AgentPlaybook[] + * GET /api/agentplaybookruns -> AgentPlaybookRun[] + * POST /api/agentplaybookruns -> 201 AgentPlaybookRun (params/models + * resolved vs the union of stage Agents) + * GET /api/agentplaybookruns/:name -> AgentPlaybookRun | 404 + * DELETE /api/agentplaybookruns/:name -> 204 (cascades to stage runs) + * GET /api/images -> AgentImage[] (agent-image catalog) + * POST /api/defaults -> SeedResult[] (seed default set, + * idempotent create-only) + * + * Catalog management routes (CatalogApi — the Hub R1 write proposal), all + * four of agents | skillcards | skillcollections | agentplaybooks: + * GET /api/ -> 200 CR[] (konveyor.io/managed=true) + * GET /api//:name -> 200 CR | 404 (unfiltered) + * POST /api/ -> 201 CR body {name, spec}; + * managed label stamped; 409 exists + * PUT /api//:name -> 200 CR body {spec}; spec replaced, + * metadata kept, label stamped; + * 404 absent, 409 conflict + * DELETE /api//:name -> 204 | 404 + * GET /api/llmproviders[/:name] -> read-only (list unfiltered) + */ +import type { + AgentImage, + AgentPlaybook, + AgentPlaybookRun, + AgentPlaybookSpec, + AgentResource, + AgentResourceSpec, + AgentRun, + Application, + CatalogApi, + CreatePlaybookRunInput, + CreateRunInput, + LLMProvider, + RunApi, + SeedResult, + SkillCard, + SkillCardSpec, + SkillCollection, + SkillCollectionSpec, +} from "../contract/index.js"; + +/** Abort any REST call that hasn't answered within this budget. */ +const REQUEST_TIMEOUT_MS = 30_000; + +export class ShimClient implements RunApi, CatalogApi { + /** Normalized base URL, no trailing slash (e.g. http://127.0.0.1:7080). */ + readonly baseUrl: string; + + constructor(baseUrl: string) { + // Validate eagerly so a bad base fails at construction, not first call. + const parsed = new URL(baseUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`ShimClient: baseUrl must be http(s), got ${parsed.protocol}//`); + } + this.baseUrl = baseUrl.replace(/\/+$/, ""); + } + + listAgents(): Promise { + return this.json("GET", "/api/agents"); + } + + getAgent(name: string): Promise { + return this.json("GET", `/api/agents/${encodeURIComponent(name)}`); + } + + listApplications(): Promise { + return this.json("GET", "/api/applications"); + } + + /** + * Like listApplications, but also returns where the inventory came from + * (from the X-Inventory-* response headers): "hub" with the endpoint, or + * "stub" when Hub was unreachable. Lets a UI show the data is live, not + * hardcoded. Falls back to source "unknown" if headers are absent (e.g. a + * Hub proxy that doesn't set them). + */ + async listApplicationsWithSource(): Promise<{ + source: "hub" | "stub" | "unknown"; + endpoint: string; + applications: Application[]; + }> { + const res = await this.send("GET", "/api/applications"); + const applications = (await res.json()) as Application[]; + const header = res.headers.get("X-Inventory-Source"); + const source = header === "hub" || header === "stub" ? header : "unknown"; + return { source, endpoint: res.headers.get("X-Inventory-Endpoint") ?? "", applications }; + } + + listRuns(): Promise { + return this.json("GET", "/api/agentruns"); + } + + createRun(input: CreateRunInput): Promise { + return this.json("POST", "/api/agentruns", input); + } + + getRun(name: string): Promise { + return this.json("GET", `/api/agentruns/${encodeURIComponent(name)}`); + } + + listPlaybooks(): Promise { + return this.json("GET", "/api/agentplaybooks"); + } + + listPlaybookRuns(): Promise { + return this.json("GET", "/api/agentplaybookruns"); + } + + getPlaybookRun(name: string): Promise { + return this.json("GET", `/api/agentplaybookruns/${encodeURIComponent(name)}`); + } + + createPlaybookRun(input: CreatePlaybookRunInput): Promise { + return this.json("POST", "/api/agentplaybookruns", input); + } + + async deletePlaybookRun(name: string): Promise { + await this.send("DELETE", `/api/agentplaybookruns/${encodeURIComponent(name)}`); + } + + async deleteRun(name: string): Promise { + await this.send("DELETE", `/api/agentruns/${encodeURIComponent(name)}`); + } + + listImages(): Promise { + return this.json("GET", "/api/images"); + } + + loadDefaults(): Promise { + return this.json("POST", "/api/defaults"); + } + + // ---------------------------------------------------- CatalogApi (reads) + + listProviders(): Promise { + return this.json("GET", "/api/llmproviders"); + } + + getProvider(name: string): Promise { + return this.json("GET", `/api/llmproviders/${encodeURIComponent(name)}`); + } + + listSkillCards(): Promise { + return this.json("GET", "/api/skillcards"); + } + + getSkillCard(name: string): Promise { + return this.json("GET", `/api/skillcards/${encodeURIComponent(name)}`); + } + + listSkillCollections(): Promise { + return this.json("GET", "/api/skillcollections"); + } + + getSkillCollection(name: string): Promise { + return this.json("GET", `/api/skillcollections/${encodeURIComponent(name)}`); + } + + getPlaybook(name: string): Promise { + return this.json("GET", `/api/agentplaybooks/${encodeURIComponent(name)}`); + } + + // --------------------------------------------------- CatalogApi (writes) + + createAgent(name: string, spec: AgentResourceSpec): Promise { + return this.json("POST", "/api/agents", { name, spec }); + } + + updateAgent(name: string, spec: AgentResourceSpec): Promise { + return this.json("PUT", `/api/agents/${encodeURIComponent(name)}`, { spec }); + } + + async deleteAgent(name: string): Promise { + await this.send("DELETE", `/api/agents/${encodeURIComponent(name)}`); + } + + createSkillCard(name: string, spec: SkillCardSpec): Promise { + return this.json("POST", "/api/skillcards", { name, spec }); + } + + updateSkillCard(name: string, spec: SkillCardSpec): Promise { + return this.json("PUT", `/api/skillcards/${encodeURIComponent(name)}`, { spec }); + } + + async deleteSkillCard(name: string): Promise { + await this.send("DELETE", `/api/skillcards/${encodeURIComponent(name)}`); + } + + createSkillCollection(name: string, spec: SkillCollectionSpec): Promise { + return this.json("POST", "/api/skillcollections", { name, spec }); + } + + updateSkillCollection(name: string, spec: SkillCollectionSpec): Promise { + return this.json( + "PUT", + `/api/skillcollections/${encodeURIComponent(name)}`, + { spec }, + ); + } + + async deleteSkillCollection(name: string): Promise { + await this.send("DELETE", `/api/skillcollections/${encodeURIComponent(name)}`); + } + + createPlaybook(name: string, spec: AgentPlaybookSpec): Promise { + return this.json("POST", "/api/agentplaybooks", { name, spec }); + } + + updatePlaybook(name: string, spec: AgentPlaybookSpec): Promise { + return this.json("PUT", `/api/agentplaybooks/${encodeURIComponent(name)}`, { + spec, + }); + } + + async deletePlaybook(name: string): Promise { + await this.send("DELETE", `/api/agentplaybooks/${encodeURIComponent(name)}`); + } + + /** + * ws(s):// URL of the shim's ACP tunnel for a run, derived from baseUrl + * (http -> ws, https -> wss; any base path prefix is preserved). Pass to + * AcpSession.connect — no secretKey needed, the shim injects it upstream. + */ + acpUrl(runName: string): string { + const u = new URL(this.baseUrl); + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; + const prefix = u.pathname.replace(/\/+$/, ""); + u.pathname = `${prefix}/api/agentruns/${encodeURIComponent(runName)}/acp`; + u.search = ""; + u.hash = ""; + return u.toString(); + } + + // ------------------------------------------------------------ internals + + private async send(method: string, path: string, body?: unknown): Promise { + const url = this.baseUrl + path; + let res: Response; + try { + res = await fetch(url, { + method, + headers: body !== undefined ? { "content-type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + // fetch has no built-in timeout; without this a stalled shim or a + // network partition hangs every RunApi call forever. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + throw new Error( + `${method} ${url} failed: ${err instanceof Error ? err.message : String(err)} — is the hub-shim running?`, + ); + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `${method} ${url} failed: HTTP ${res.status}${text ? ` — ${text.slice(0, 300)}` : ""}`, + ); + } + return res; + } + + private async json(method: string, path: string, body?: unknown): Promise { + const res = await this.send(method, path, body); + return (await res.json()) as T; + } +} diff --git a/clients/packages/agentic-client/tsconfig.json b/clients/packages/agentic-client/tsconfig.json new file mode 100644 index 00000000..c3cc3c0a --- /dev/null +++ b/clients/packages/agentic-client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "dev"] +} diff --git a/clients/packages/agentrun-client/dev/demo.ts b/clients/packages/agentrun-client/dev/demo.ts new file mode 100644 index 00000000..d8000d7c --- /dev/null +++ b/clients/packages/agentrun-client/dev/demo.ts @@ -0,0 +1,92 @@ +/** + * End-to-end demo of the VSCode-extension flow against minikube: + * + * create AgentRun CR -> (simulator reconciles) -> wait for Running + + * secretKeyRef -> read key -> port-forward -> ACP over WebSocket -> + * session/new -> session/prompt (streamed) -> reconnect + session/load + * (history replay), proving the multi-client/reattach story. + * + * Run `npm run simulator` in another terminal first. + */ +import type * as acp from "@agentclientprotocol/sdk"; +import { AgentRunClient } from "../src/kube.js"; +import { openTunnel } from "../src/portforward.js"; +import { withRunConnection, targetFromEndpoint } from "../src/acp.js"; + +const client = new AgentRunClient(); + +function renderUpdate(prefix: string) { + return (n: acp.SessionNotification) => { + const u = n.update; + switch (u.sessionUpdate) { + case "agent_message_chunk": + if (u.content.type === "text") process.stdout.write(u.content.text); + break; + case "tool_call": + console.log(`${prefix} [tool_call] ${u.title} (${u.status})`); + break; + case "tool_call_update": + console.log(`${prefix} [tool_call_update] ${u.toolCallId} -> ${u.status}`); + break; + default: + console.log(`${prefix} [${u.sessionUpdate}]`); + } + }; +} + +console.log("1. Creating AgentRun (the extension builds this from the open workspace)..."); +const run = await client.createAgentRun( + { + agentRef: "migration-analyzer", + instructions: "Analyze the coolstore app for EAP8 migration blockers.", + params: [ + { name: "repository", value: "https://github.com/konveyor-ecosystem/coolstore.git" }, + // "branch" omitted on purpose: exercises Agent param defaults + ], + }, + { generateName: "demo-" }, +); +const name = run.metadata.name!; +console.log(` created AgentRun ${name}`); + +console.log("2. Waiting for Running + secretKeyRef (simulator provisions the sandbox)..."); +const endpoint = await client.waitForAcpEndpoint(name, { timeoutMs: 90_000 }); +console.log(` pod=${endpoint.podName} service=${endpoint.serviceHost}:${endpoint.port}`); + +console.log("3. Opening port-forward tunnel..."); +const tunnel = await openTunnel(client.kc, client.namespace, endpoint.podName, endpoint.port); +console.log(` 127.0.0.1:${tunnel.localPort} -> ${endpoint.podName}:${endpoint.port}`); + +let savedSessionId = ""; +try { + const target = targetFromEndpoint(endpoint, tunnel.localPort); + + console.log("4. Connecting ACP over WebSocket (X-Secret-Key auth)...\n"); + await withRunConnection(target, { onSessionUpdate: renderUpdate(" live") }, async (conn) => { + console.log( + ` initialized: protocol v${conn.initialized.protocolVersion}, loadSession=${conn.initialized.agentCapabilities?.loadSession}`, + ); + const session = await conn.newSession(); + savedSessionId = session.sessionId; + console.log(` session ${savedSessionId}\n--- streamed output ---`); + const result = await conn.prompt(savedSessionId, "Start the migration analysis."); + console.log(`--- turn done: ${result.stopReason} ---\n`); + }); + + console.log("5. Reconnecting fresh + session/load (full history replay)...\n"); + let replayed = 0; + await withRunConnection( + target, + { onSessionUpdate: () => void replayed++ }, + async (conn) => { + await conn.loadSession(savedSessionId); + console.log(` replayed ${replayed} updates from session ${savedSessionId}`); + }, + ); + + console.log(`\nE2E OK. Inspect with:`); + console.log(` kubectl get agentrun ${name} -n ${client.namespace} -o yaml`); + console.log(` kubectl logs ${endpoint.podName} -n ${client.namespace}`); +} finally { + tunnel.close(); +} diff --git a/clients/packages/agentrun-client/dev/local-smoke.ts b/clients/packages/agentrun-client/dev/local-smoke.ts new file mode 100644 index 00000000..1fa4c561 --- /dev/null +++ b/clients/packages/agentrun-client/dev/local-smoke.ts @@ -0,0 +1,70 @@ +/** + * Local smoke test: exercises src/acp.ts against a mock harness running on + * localhost (no cluster involved). Covers WS auth, streaming updates, + * prompt turn completion, and session/load replay. + * + * GOOSE_SERVER__SECRET_KEY=localtest PORT=4100 node ../../harness-mock/server.mjs & + * npx tsx dev/local-smoke.ts + */ +import { withRunConnection } from "../src/acp.js"; + +const target = { + host: "127.0.0.1", + port: Number(process.env.PORT ?? 4100), + secretKey: process.env.GOOSE_SERVER__SECRET_KEY ?? "localtest", +}; + +let updates = 0; +let text = ""; +let sessionId = ""; + +await withRunConnection( + target, + { + onSessionUpdate: (n) => { + updates++; + const u = n.update; + if (u.sessionUpdate === "agent_message_chunk" && u.content.type === "text") { + text += u.content.text; + } + }, + }, + async (conn) => { + if (conn.initialized.agentCapabilities?.loadSession !== true) { + throw new Error("expected loadSession capability"); + } + const session = await conn.newSession(); + sessionId = session.sessionId; + const result = await conn.prompt(sessionId, "hello from local smoke test"); + if (result.stopReason !== "end_turn") throw new Error(`unexpected stop: ${result.stopReason}`); + }, +); + +if (updates < 4) throw new Error(`expected streamed updates, got ${updates}`); +if (!text.includes("hello from local smoke test")) throw new Error("echo missing from stream"); + +// Reconnect fresh and replay history. +let replayed = 0; +await withRunConnection( + target, + { onSessionUpdate: () => void replayed++ }, + async (conn) => { + await conn.loadSession(sessionId); + }, +); +if (replayed !== updates) { + throw new Error(`replay mismatch: live=${updates} replayed=${replayed}`); +} + +console.log(`OK: ${updates} live updates, ${replayed} replayed, echo present, auth enforced`); + +// Negative: wrong key must fail the connection. +try { + await withRunConnection({ ...target, secretKey: "wrong" }, {}, async (conn) => { + await conn.newSession(); + }); + throw new Error("connection with wrong key unexpectedly succeeded"); +} catch (err) { + if (err instanceof Error && err.message.includes("unexpectedly")) throw err; + console.log(`OK: wrong X-Secret-Key rejected (${(err as Error).constructor.name})`); +} diff --git a/clients/packages/agentrun-client/package-lock.json b/clients/packages/agentrun-client/package-lock.json new file mode 100644 index 00000000..e3c1a347 --- /dev/null +++ b/clients/packages/agentrun-client/package-lock.json @@ -0,0 +1,1401 @@ +{ + "name": "@konveyor/agentrun-client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@konveyor/agentrun-client", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@agentclientprotocol/sdk": "^1.1.0", + "@kubernetes/client-node": "^1.4.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "@types/ws": "^8.18.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@kubernetes/client-node": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", + "license": "Apache-2.0", + "dependencies": { + "@types/js-yaml": "^4.0.1", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", + "@types/stream-buffers": "^3.0.3", + "form-data": "^4.0.0", + "hpagent": "^1.2.0", + "isomorphic-ws": "^5.0.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "node-fetch": "^2.7.0", + "openid-client": "^6.1.3", + "rfc4648": "^1.3.0", + "socks-proxy-agent": "^8.0.4", + "stream-buffers": "^3.0.2", + "tar-fs": "^3.0.9", + "ws": "^8.18.2" + } + }, + "node_modules/@kubernetes/client-node/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/stream-buffers": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz", + "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/clients/packages/agentrun-client/package.json b/clients/packages/agentrun-client/package.json new file mode 100644 index 00000000..adc10a4f --- /dev/null +++ b/clients/packages/agentrun-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "@konveyor/agentrun-client", + "version": "1.0.0", + "description": "", + "main": "dist/src/index.js", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "demo": "tsx dev/demo.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@agentclientprotocol/sdk": "^1.1.0", + "@kubernetes/client-node": "^1.4.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "@types/ws": "^8.18.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + }, + "types": "dist/src/index.d.ts" +} diff --git a/clients/packages/agentrun-client/src/acp.ts b/clients/packages/agentrun-client/src/acp.ts new file mode 100644 index 00000000..2e3910ef --- /dev/null +++ b/clients/packages/agentrun-client/src/acp.ts @@ -0,0 +1,110 @@ +/** + * ACP-side client: connects to a run's `goose serve` endpoint + * (ws://:4000/acp, authenticated with X-Secret-Key per ADR 0002) + * using the official @agentclientprotocol/sdk WebSocket transport. + * + * In a VSCode extension host this runs under Node, so we pass the `ws` + * constructor explicitly — browser WebSocket cannot set upgrade headers. + */ +import { WebSocket } from "ws"; +import * as acp from "@agentclientprotocol/sdk"; +import { createWebSocketStream } from "@agentclientprotocol/sdk/experimental/ws-client"; +import type { AcpEndpoint } from "./kube.js"; + +export const ACP_PATH = "/acp"; +export const SECRET_KEY_HEADER = "X-Secret-Key"; + +export interface AcpTarget { + host: string; + port: number; + secretKey: string; +} + +export function targetFromEndpoint(endpoint: AcpEndpoint, localPort?: number): AcpTarget { + return localPort !== undefined + ? { host: "127.0.0.1", port: localPort, secretKey: endpoint.secretKey } + : { host: endpoint.serviceHost, port: endpoint.port, secretKey: endpoint.secretKey }; +} + +export interface RunClientHandlers { + /** Streaming session updates (message chunks, tool calls, plans...). */ + onSessionUpdate?: (notification: acp.SessionNotification) => void | Promise; + /** + * Human-in-the-loop approval. In the extension this maps onto the diff + * preview UX; the default rejects nothing and picks the first option. + */ + onPermissionRequest?: ( + request: acp.RequestPermissionRequest, + ) => acp.RequestPermissionResponse | Promise; + clientName?: string; +} + +export interface RunConnection { + initialized: acp.InitializeResponse; + ctx: acp.ClientContext; + /** Starts a fresh session in the sandbox workspace. */ + newSession(cwd?: string): Promise; + /** Replays full history for an existing session, then streams live. */ + loadSession(sessionId: string, cwd?: string): Promise; + /** Sends a text prompt and resolves when the turn completes. */ + prompt(sessionId: string, text: string): Promise; + cancel(sessionId: string): Promise; +} + +/** + * Opens an authenticated ACP connection and runs `op` over it. The + * connection is closed when `op` settles. + */ +export async function withRunConnection( + target: AcpTarget, + handlers: RunClientHandlers, + op: (conn: RunConnection) => Promise, +): Promise { + const stream = createWebSocketStream(`ws://${target.host}:${target.port}${ACP_PATH}`, { + WebSocket, + headers: { [SECRET_KEY_HEADER]: target.secretKey }, + }); + + const app = acp + .client({ name: handlers.clientName ?? "konveyor-agentrun-client" }) + .onNotification(acp.methods.client.session.update, (c) => + handlers.onSessionUpdate?.(c.params), + ) + .onRequest(acp.methods.client.session.requestPermission, async (c) => { + if (handlers.onPermissionRequest) return handlers.onPermissionRequest(c.params); + const first = c.params.options[0]; + return { + outcome: first + ? { outcome: "selected", optionId: first.optionId } + : { outcome: "cancelled" }, + }; + }); + + try { + return await app.connectWith(stream, async (ctx) => { + const initialized = await ctx.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + + const conn: RunConnection = { + initialized, + ctx, + newSession: (cwd = "/workspace") => + ctx.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }), + loadSession: (sessionId, cwd = "/workspace") => + ctx.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), + prompt: (sessionId, text) => + ctx.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text }], + }), + cancel: (sessionId) => + ctx.notify(acp.methods.agent.session.cancel, { sessionId }), + }; + return op(conn); + }); + } finally { + await stream.writable.close().catch(() => {}); + } +} diff --git a/clients/packages/agentrun-client/src/index.ts b/clients/packages/agentrun-client/src/index.ts new file mode 100644 index 00000000..dc135b8a --- /dev/null +++ b/clients/packages/agentrun-client/src/index.ts @@ -0,0 +1,12 @@ +export * from "./types.js"; +export { AgentRunClient, type AcpEndpoint, type WaitOptions } from "./kube.js"; +export { openTunnel, type Tunnel } from "./portforward.js"; +export { + withRunConnection, + targetFromEndpoint, + ACP_PATH, + SECRET_KEY_HEADER, + type AcpTarget, + type RunClientHandlers, + type RunConnection, +} from "./acp.js"; diff --git a/clients/packages/agentrun-client/src/kube.ts b/clients/packages/agentrun-client/src/kube.ts new file mode 100644 index 00000000..e939900b --- /dev/null +++ b/clients/packages/agentrun-client/src/kube.ts @@ -0,0 +1,220 @@ +/** + * Kubernetes-side client for AgentRun CRs. + * + * This is the direct-to-apiserver path (kubeconfig auth) blessed by ADR 0002 + * for clients without the web UI. In an IDE we know the workspace's git + * remote/branch already, so we build the CR ourselves rather than going + * through Hub's smart-create (ADR 0003). Swapping this class for a Hub REST + * implementation later only changes the transport, not the interface. + */ +import * as k8s from "@kubernetes/client-node"; +import { + API_VERSION, + GROUP, + VERSION, + PLURALS, + type Agent, + type AgentRun, + type AgentRunSpec, +} from "./types.js"; + +export interface AcpEndpoint { + /** Pod running the agent (port-forward target). */ + podName: string; + /** Stable DNS name inside the cluster: ..svc */ + serviceHost: string; + port: number; + /** Value for the X-Secret-Key header. */ + secretKey: string; +} + +export interface WaitOptions { + timeoutMs?: number; + pollIntervalMs?: number; + signal?: AbortSignal; +} + +/** + * Secret data keys the ACP key may be stored under, newest-first: the real + * agentic-controller uses "secret-key"; the dev simulator used "ACP_SECRET_KEY". + */ +const SECRET_DATA_KEYS = ["secret-key", "ACP_SECRET_KEY"]; +const ACP_PORT = 4000; + +/** HTTP status carried by a @kubernetes/client-node error, or undefined. */ +function k8sStatusCode(err: unknown): number | undefined { + if (err && typeof err === "object" && "code" in err) { + const code = (err as { code: unknown }).code; + if (typeof code === "number" && code >= 400 && code <= 599) return code; + } + return undefined; +} + +export class AgentRunClient { + readonly kc: k8s.KubeConfig; + readonly namespace: string; + private readonly custom: k8s.CustomObjectsApi; + private readonly core: k8s.CoreV1Api; + + constructor(options?: { kubeConfig?: k8s.KubeConfig; namespace?: string }) { + this.kc = options?.kubeConfig ?? new k8s.KubeConfig(); + if (!options?.kubeConfig) this.kc.loadFromDefault(); + this.namespace = options?.namespace ?? "konveyor-agents"; + this.custom = this.kc.makeApiClient(k8s.CustomObjectsApi); + this.core = this.kc.makeApiClient(k8s.CoreV1Api); + } + + async getAgent(name: string): Promise { + return (await this.custom.getNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: this.namespace, + plural: PLURALS.Agent, + name, + })) as Agent; + } + + async createAgentRun( + spec: AgentRunSpec, + options?: { name?: string; generateName?: string; labels?: Record }, + ): Promise { + const body: AgentRun = { + apiVersion: API_VERSION, + kind: "AgentRun", + metadata: { + ...(options?.name + ? { name: options.name } + : { generateName: options?.generateName ?? `${spec.agentRef}-` }), + namespace: this.namespace, + labels: options?.labels, + }, + spec, + }; + return (await this.custom.createNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: this.namespace, + plural: PLURALS.AgentRun, + body, + })) as AgentRun; + } + + async getAgentRun(name: string): Promise { + return (await this.custom.getNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: this.namespace, + plural: PLURALS.AgentRun, + name, + })) as AgentRun; + } + + async deleteAgentRun(name: string): Promise { + await this.custom.deleteNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: this.namespace, + plural: PLURALS.AgentRun, + name, + }); + } + + /** + * Waits until `predicate` returns truthy for the run, polling the + * apiserver. Polling keeps this dependency-free and works on every + * cluster; swap in a Watch if churn matters. + */ + async waitFor( + name: string, + predicate: (run: AgentRun) => T | undefined | false, + options?: WaitOptions, + ): Promise { + const timeoutMs = options?.timeoutMs ?? 120_000; + const interval = options?.pollIntervalMs ?? 1_000; + const deadline = Date.now() + timeoutMs; + for (;;) { + options?.signal?.throwIfAborted(); + const run = await this.getAgentRun(name); + const result = predicate(run); + if (result) return result; + if (run.status?.phase === "Failed") { + const msg = run.status.conditions?.map((c) => c.message).filter(Boolean).join("; "); + throw new Error(`AgentRun ${name} failed${msg ? `: ${msg}` : ""}`); + } + if (Date.now() > deadline) { + throw new Error( + `Timed out after ${timeoutMs}ms waiting for AgentRun ${name} (phase=${run.status?.phase ?? "unset"})`, + ); + } + await new Promise((r) => setTimeout(r, interval)); + } + } + + /** + * Waits for the run to be connectable (Running, sandbox up, ACP key + * published) and resolves the pieces needed to open an ACP connection. + */ + async waitForAcpEndpoint(name: string, options?: WaitOptions): Promise { + const ready = await this.waitFor( + name, + (run) => + run.status?.phase === "Running" && + run.status.sandboxName && + run.status.secretKeyRef?.name + ? { sandboxName: run.status.sandboxName, secretName: run.status.secretKeyRef.name } + : undefined, + options, + ); + + const secret = await this.core.readNamespacedSecret({ + name: ready.secretName, + namespace: this.namespace, + }); + const data = secret.data ?? {}; + // Prefer a known key; tolerate a harness that picked another name as long + // as the secret holds exactly one entry. + const b64 = + SECRET_DATA_KEYS.map((k) => data[k]).find((v) => v !== undefined) ?? + (Object.keys(data).length === 1 ? Object.values(data)[0] : undefined); + if (!b64) { + throw new Error( + `Secret ${ready.secretName} has no ACP key (looked for ${SECRET_DATA_KEYS.join( + ", ", + )}; keys present: ${Object.keys(data).join(", ")})`, + ); + } + + // Both the real Agent Sandbox controller and the dev simulator name the + // backing pod after the Sandbox, so resolve it by name first — the only + // mechanism that works against every controller build. Fall back to the + // run label, which controllers since #34 mirror onto the pod + // (pre-#34 pods carry only agents.x-k8s.io/sandbox-name-hash). + let podName = await this.core + .readNamespacedPod({ name: ready.sandboxName, namespace: this.namespace }) + .then((p) => p.metadata?.name) + .catch((err) => { + // Only 404 means "fall through to the label selector" — anything + // else (RBAC, network, 5xx) is a real failure the caller must see. + if (k8sStatusCode(err) === 404) return undefined; + throw err; + }); + if (!podName) { + const pods = await this.core.listNamespacedPod({ + namespace: this.namespace, + labelSelector: `konveyor.io/agentrun=${name}`, + }); + const pod = pods.items.find((p) => p.status?.phase === "Running") ?? pods.items[0]; + podName = pod?.metadata?.name; + } + if (!podName) { + throw new Error(`No sandbox pod found for AgentRun ${name} (sandbox ${ready.sandboxName})`); + } + + return { + podName, + serviceHost: `${ready.sandboxName}.${this.namespace}.svc`, + port: ACP_PORT, + secretKey: Buffer.from(b64, "base64").toString("utf8"), + }; + } +} diff --git a/clients/packages/agentrun-client/src/portforward.ts b/clients/packages/agentrun-client/src/portforward.ts new file mode 100644 index 00000000..818da419 --- /dev/null +++ b/clients/packages/agentrun-client/src/portforward.ts @@ -0,0 +1,82 @@ +/** + * Local TCP listener that tunnels connections to a pod port via the + * Kubernetes port-forward subresource — the programmatic equivalent of + * `kubectl port-forward :4000`. + * + * Used when the client machine has no route to cluster service DNS (the + * normal laptop-to-minikube case). Inside the cluster, or behind Hub's + * stream proxy, connect to the service host directly instead. + */ +import * as net from "node:net"; +import { inspect } from "node:util"; +import * as k8s from "@kubernetes/client-node"; + +export interface Tunnel { + localPort: number; + close(): void; +} + +export async function openTunnel( + kc: k8s.KubeConfig, + namespace: string, + podName: string, + targetPort: number, +): Promise { + const forward = new k8s.PortForward(kc); + const server = net.createServer((socket) => { + // Without a listener, a socket 'error' (destroy(err) below, or a + // mid-stream failure surfaced by the port-forward piping) is an + // unhandled 'error' event and kills the whole process. + socket.on("error", (err) => { + console.error(`[portforward] ${podName}:${targetPort} socket error: ${err.message}`); + }); + forward + .portForward(namespace, podName, [targetPort], socket, null, socket) + .then((wsOrFactory) => { + // With retryCount=0 (our call) this resolves with the API-server + // WebSocket carrying the forward. The library pipes pod→local data + // but never propagates upstream teardown: when the pod-side TCP + // connection dies, the kubelet closes this WebSocket and the local + // socket would sit open forever — whoever dialed through the tunnel + // keeps waiting on a dead peer. Mirror the WS lifecycle onto the + // local socket, and release the WS when the local socket goes first. + if (typeof wsOrFactory === "function") return; + const ws = wsOrFactory; + ws.on("close", (code: number) => { + if (!socket.destroyed) { + socket.destroy( + new Error(`port-forward to ${podName}:${targetPort} closed by upstream (code=${code})`), + ); + } + }); + ws.on("error", (err: Error) => { + if (!socket.destroyed) { + socket.destroy( + new Error(`port-forward to ${podName}:${targetPort} errored: ${err.message}`), + ); + } + }); + socket.on("close", () => ws.close()); + }) + .catch((err) => { + // Rejections are often ws ErrorEvents, not Errors — grab .message + // before falling back to a full inspect dump. + const reason = + typeof (err as { message?: unknown })?.message === "string" + ? (err as { message: string }).message + : inspect(err, { depth: 2 }); + socket.destroy(new Error(`port-forward to ${podName}:${targetPort} failed: ${reason}`)); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address() as net.AddressInfo; + return { + localPort: address.port, + close: () => server.close(), + }; +} diff --git a/clients/packages/agentrun-client/src/types.ts b/clients/packages/agentrun-client/src/types.ts new file mode 100644 index 00000000..ca25a0ea --- /dev/null +++ b/clients/packages/agentrun-client/src/types.ts @@ -0,0 +1,300 @@ +/** + * TypeScript mirrors of the konveyor.io/v1alpha1 CRD types. + * + * Source of truth: github.com/konveyor/agentic-controller api/v1alpha1/*.go + * Keep field names and optionality in sync with the Go structs. + */ + +export const GROUP = "konveyor.io"; +export const VERSION = "v1alpha1"; +export const API_VERSION = `${GROUP}/${VERSION}`; + +export interface ObjectMeta { + name?: string; + generateName?: string; + namespace?: string; + labels?: Record; + annotations?: Record; + uid?: string; + resourceVersion?: string; + creationTimestamp?: string; +} + +export interface Condition { + type: string; + status: "True" | "False" | "Unknown"; + reason?: string; + message?: string; + lastTransitionTime?: string; + observedGeneration?: number; +} + +export interface LocalObjectReference { + name: string; +} + +export interface EnvVar { + name: string; + value?: string; + valueFrom?: unknown; +} + +export interface EnvFromSource { + configMapRef?: LocalObjectReference & { optional?: boolean }; + secretRef?: LocalObjectReference & { optional?: boolean }; + prefix?: string; +} + +// ---------------------------------------------------------------- AgentRun + +export type AgentRunPhase = "Pending" | "Running" | "Succeeded" | "Failed"; + +export interface AgentRunModelSelection { + /** Purpose of this model in the run, e.g. "primary", "efficient". */ + role: string; + /** Name of an LLMProvider CR; must be in the Agent's providers list. */ + provider: string; + /** Model identifier declared on the referenced LLMProvider. */ + model: string; +} + +export interface AgentRunParam { + /** Matches an Agent param declaration. */ + name: string; + value: string; +} + +export interface AgentRunSpec { + /** Name of the Agent CR to execute. Immutable. */ + agentRef: string; + models?: AgentRunModelSelection[]; + /** Injected as KONVEYOR_PARAM_{NAME} env vars into the Sandbox. */ + params?: AgentRunParam[]; + /** Task-specific instructions, composed with the Agent's prompt. */ + instructions?: string; + env?: EnvVar[]; + envFrom?: EnvFromSource[]; +} + +export interface AgentRunStatus { + phase?: AgentRunPhase; + observedGeneration?: number; + /** Name of the Sandbox CR created for this run. */ + sandboxName?: string; + startTime?: string; + completionTime?: string; + /** Wall-clock duration of the run in seconds. */ + duration?: number; + /** + * References a Secret containing the ACP authentication key for + * connecting to the agent's ACP endpoint (goose serve, port 4000). + */ + secretKeyRef?: LocalObjectReference; + conditions?: Condition[]; +} + +export interface AgentRun { + apiVersion: typeof API_VERSION; + kind: "AgentRun"; + metadata: ObjectMeta; + spec: AgentRunSpec; + status?: AgentRunStatus; +} + +// ----------------------------------------------------------- AgentPlaybook + +export interface AgentPlaybookStage { + /** Stage name, unique within the playbook. */ + name: string; + /** Name of the Agent CR to execute for this stage. */ + agentRef: string; + /** Task-specific instructions, composed with the Agent's prompt. */ + instructions?: string; +} + +export interface AgentPlaybookSpec { + /** High-level guide injected into every stage as the + * KONVEYOR_PLAYBOOK_INSTRUCTIONS env var, composed into the prompt. */ + guide?: string; + /** Ordered stages; executed sequentially on a shared target branch. */ + stages: AgentPlaybookStage[]; +} + +export interface AgentPlaybook { + apiVersion: typeof API_VERSION; + kind: "AgentPlaybook"; + metadata: ObjectMeta; + spec: AgentPlaybookSpec; + status?: { observedGeneration?: number; conditions?: Condition[] }; +} + +// -------------------------------------------------------- AgentPlaybookRun + +export interface AgentPlaybookRunStageStatus { + /** Stage name, matching a stage in the AgentPlaybook. */ + name: string; + phase: AgentRunPhase; + /** Name of the AgentRun created for this stage (hash-truncated past 63 + * chars — always read this rather than recomputing "-"). */ + agentRunName?: string; +} + +/** Spec is whole-spec immutable (CEL) — delete and recreate to change. */ +export interface AgentPlaybookRunSpec { + /** Name of the AgentPlaybook CR to execute. */ + playbookRef: string; + /** Model selections applied to every stage (no per-stage overrides). */ + models?: AgentRunModelSelection[]; + /** Param values forwarded wholesale to every stage's AgentRun. */ + params?: AgentRunParam[]; + env?: EnvVar[]; + envFrom?: EnvFromSource[]; +} + +export interface AgentPlaybookRunStatus { + phase?: AgentRunPhase; + observedGeneration?: number; + /** + * Stage currently executing. Cleared only when the run Succeeds — on a + * Failed run it stays set to the stage that failed. Use phase (or + * completionTime) as the terminal test, never this field. + */ + currentStage?: string; + stages?: AgentPlaybookRunStageStatus[]; + startTime?: string; + completionTime?: string; + /** NOTE: no duration field (unlike AgentRunStatus) — compute it from + * startTime/completionTime. Ready=False reason=StageRunning is the + * NORMAL healthy state while executing, not an error. */ + conditions?: Condition[]; +} + +export interface AgentPlaybookRun { + apiVersion: typeof API_VERSION; + kind: "AgentPlaybookRun"; + metadata: ObjectMeta; + spec: AgentPlaybookRunSpec; + status?: AgentPlaybookRunStatus; +} + +// ------------------------------------------------------------------- Agent + +export type AgentParamType = "string" | "number" | "boolean"; + +export interface AgentParam { + name: string; + type?: AgentParamType; + description?: string; + default?: string; + required?: boolean; +} + +export interface AgentSpec { + /** Container image carrying the agent runtime and toolchains. */ + image: string; + /** Standing instructions, composed with AgentRun instructions. */ + prompt?: string; + providers: { ref: string }[]; + skillCards?: { ref: string }[]; + skillCollections?: { ref: string }[]; + params?: AgentParam[]; +} + +export interface Agent { + apiVersion: typeof API_VERSION; + kind: "Agent"; + metadata: ObjectMeta; + spec: AgentSpec; + status?: { observedGeneration?: number; conditions?: Condition[] }; +} + +// ------------------------------------------------------------- LLMProvider + +export interface LLMProviderModel { + name: string; + contextWindow: number; + tier?: string; +} + +export interface LLMProviderSpec { + endpoint: string; + /** + * key names the Secret entry injected as KONVEYOR_MODEL__API_KEY. + * Omitted (keyless): the controller exposes the whole Secret to the + * sandbox via envFrom — the multi-variable/SigV4 path. + */ + credentialRef: { secretName: string; key?: string }; + models: LLMProviderModel[]; +} + +export interface LLMProvider { + apiVersion: typeof API_VERSION; + kind: "LLMProvider"; + metadata: ObjectMeta; + spec: LLMProviderSpec; + status?: { + observedGeneration?: number; + connectionVerified?: boolean; + discoveredModels?: string[]; + conditions?: Condition[]; + }; +} + +// --------------------------------------------------------------- SkillCard + +export interface SkillCardSpec { + displayName?: string; + description?: string; + /** Exactly one of image | inline | source provides the skill content. */ + image?: string; + inline?: string; + source?: string; + tags?: string[]; + type?: string; + version?: string; +} + +export interface SkillCard { + apiVersion: typeof API_VERSION; + kind: "SkillCard"; + metadata: ObjectMeta; + spec: SkillCardSpec; + status?: { + observedGeneration?: number; + resolvedImage?: string; + conditions?: Condition[]; + }; +} + +// --------------------------------------------------------- SkillCollection + +export interface SkillCollectionSkillRef { + /** Local name for this skill within the collection. */ + name: string; + /** Exactly one of skillCardRef | image | source must be set. */ + skillCardRef?: string; + image?: string; + source?: string; +} + +export interface SkillCollectionSpec { + skills?: SkillCollectionSkillRef[]; +} + +export interface SkillCollection { + apiVersion: typeof API_VERSION; + kind: "SkillCollection"; + metadata: ObjectMeta; + spec: SkillCollectionSpec; + status?: { observedGeneration?: number; conditions?: Condition[] }; +} + +export const PLURALS = { + AgentRun: "agentruns", + AgentPlaybook: "agentplaybooks", + AgentPlaybookRun: "agentplaybookruns", + Agent: "agents", + LLMProvider: "llmproviders", + SkillCard: "skillcards", + SkillCollection: "skillcollections", +} as const; diff --git a/clients/packages/agentrun-client/tsconfig.json b/clients/packages/agentrun-client/tsconfig.json new file mode 100644 index 00000000..233f2115 --- /dev/null +++ b/clients/packages/agentrun-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "dev/**/*.ts"] +} diff --git a/clients/packages/hub-shim/dev/browser-smoke.ts b/clients/packages/hub-shim/dev/browser-smoke.ts new file mode 100644 index 00000000..a808d055 --- /dev/null +++ b/clients/packages/hub-shim/dev/browser-smoke.ts @@ -0,0 +1,353 @@ +/** + * Browser-emulating smoke test for the hub-shim. + * + * Uses ONLY globalThis.fetch and globalThis.WebSocket — no 'ws' import, no + * custom headers — exactly what a browser UI can do. The ACP JSON-RPC 2.0 + * framing is spoken directly over the WebSocket (method names mirror + * @agentclientprotocol/sdk acp.methods: initialize, session/new, + * session/load, session/prompt; notifications session/update; server→client + * request session/request_permission). + * + * Run with the shim already up: npm run smoke (SHIM_URL overrides base) + * Exits non-zero if any step fails. Creates one AgentRun and deletes it. + */ + +const BASE = process.env.SHIM_URL ?? "http://127.0.0.1:7080"; +const WS_BASE = BASE.replace(/^http/, "ws"); +const RUNNING_TIMEOUT_MS = 120_000; +const RPC_TIMEOUT_MS = 60_000; + +let failures = 0; +function pass(step: string, detail?: string): void { + console.log(`PASS ${step}${detail ? ` — ${detail}` : ""}`); +} +function fail(step: string, detail: string): never { + failures++; + console.error(`FAIL ${step} — ${detail}`); + throw new SmokeAbort(step); +} +class SmokeAbort extends Error { + constructor(step: string) { + super(`aborted at step: ${step}`); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------------------ tiny ACP rpc + +interface RpcResponse { + jsonrpc: "2.0"; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +type SessionUpdateParams = { + sessionId: string; + update: { sessionUpdate: string; [k: string]: unknown }; +}; + +async function frameToText(data: unknown): Promise { + if (typeof data === "string") return data; + if (data instanceof Blob) return data.text(); + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + throw new Error(`unexpected WebSocket frame type: ${Object.prototype.toString.call(data)}`); +} + +/** Minimal browser-side ACP peer: requests out, notifications/requests in. */ +class BrowserAcpSocket { + readonly updates: SessionUpdateParams[] = []; + private nextId = 1; + private readonly pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + + private constructor(private readonly ws: WebSocket) {} + + static open(url: string): Promise { + return new Promise((resolve, reject) => { + // Browser constraint: no headers argument exists — the shim injects + // X-Secret-Key on the upstream leg. + const ws = new WebSocket(url); + const sock = new BrowserAcpSocket(ws); + ws.addEventListener("open", () => resolve(sock), { once: true }); + ws.addEventListener("close", (ev: CloseEvent) => { + reject(new Error(`socket closed before open (code=${ev.code} reason=${ev.reason})`)); + sock.failAllPending(new Error(`socket closed (code=${ev.code} reason=${ev.reason})`)); + }); + ws.addEventListener("error", () => { + reject(new Error("socket error before open")); + }); + ws.addEventListener("message", (ev: MessageEvent) => { + void frameToText(ev.data) + .then((text) => sock.handleFrame(text)) + .catch((err: unknown) => console.error("bad frame:", err)); + }); + }); + } + + private failAllPending(err: Error): void { + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + + private handleFrame(text: string): void { + const msg = JSON.parse(text) as RpcResponse; + if (msg.id !== undefined && msg.method === undefined) { + // Response to one of our requests. + const entry = this.pending.get(msg.id as number); + if (!entry) return; + this.pending.delete(msg.id as number); + if (msg.error) entry.reject(new Error(`${msg.method ?? "rpc"} error ${msg.error.code}: ${msg.error.message}`)); + else entry.resolve(msg.result); + return; + } + if (msg.method === "session/update") { + this.updates.push(msg.params as SessionUpdateParams); + return; + } + if (msg.id !== undefined && msg.method === "session/request_permission") { + // Auto-approve: pick the first offered option. + const params = msg.params as { + options?: { optionId: string; name: string; kind: string }[]; + }; + const optionId = params.options?.[0]?.optionId; + this.ws.send( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { outcome: { outcome: "selected", optionId } }, + }), + ); + return; + } + if (msg.id !== undefined && msg.method !== undefined) { + this.ws.send( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: `unsupported method ${msg.method}` }, + }), + ); + } + // Other notifications: ignore. + } + + request(method: string, params: unknown, timeoutMs = RPC_TIMEOUT_MS): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, + }); + this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); + }); + } + + close(): Promise { + return new Promise((resolve) => { + if (this.ws.readyState === WebSocket.CLOSED) return resolve(); + this.ws.addEventListener("close", () => resolve(), { once: true }); + this.ws.close(1000, "smoke done"); + }); + } +} + +// ----------------------------------------------------------------- helpers + +async function fetchJson(path: string, init?: RequestInit): Promise<{ status: number; body: unknown }> { + const res = await fetch(`${BASE}${path}`, init); + const text = await res.text(); + let body: unknown = undefined; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { status: res.status, body }; +} + +type RunView = { + metadata?: { name?: string }; + status?: { phase?: string; conditions?: { message?: string }[] }; +}; + +// -------------------------------------------------------------------- main + +async function main(): Promise { + let runName: string | undefined; + try { + // healthz + { + const res = await fetch(`${BASE}/healthz`); + const text = await res.text(); + if (res.status !== 200 || text !== "ok") fail("healthz", `status=${res.status} body=${text}`); + pass("healthz"); + } + + // list agents + { + const { status, body } = await fetchJson("/api/agents"); + if (status !== 200 || !Array.isArray(body)) fail("list-agents", `status=${status}`); + const names = (body as { metadata?: { name?: string } }[]).map((a) => a.metadata?.name); + if (!names.includes("migration-analyzer")) { + fail("list-agents", `migration-analyzer not in [${names.join(", ")}]`); + } + pass("list-agents", `agents: ${names.join(", ")}`); + } + + // create run + { + const { status, body } = await fetchJson("/api/agentruns", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agentRef: "migration-analyzer", + params: { + repository: "https://github.com/konveyor-ecosystem/coolstore", + branch: "main", + }, + instructions: "UI smoke", + }), + }); + const created = body as RunView; + if (status !== 201 || !created?.metadata?.name) { + fail("create-run", `status=${status} body=${JSON.stringify(body)}`); + } + runName = created.metadata!.name!; + pass("create-run", runName); + } + + // poll until Running + { + const deadline = Date.now() + RUNNING_TIMEOUT_MS; + let phase = "unset"; + for (;;) { + const { status, body } = await fetchJson(`/api/agentruns/${runName}`); + if (status !== 200) fail("wait-running", `GET run status=${status}`); + const run = body as RunView; + phase = run.status?.phase ?? "unset"; + if (phase === "Running") break; + if (phase === "Failed") { + const msgs = (run.status?.conditions ?? []).map((c) => c.message).filter(Boolean); + fail("wait-running", `run Failed: ${msgs.join("; ")}`); + } + if (Date.now() > deadline) { + fail("wait-running", `timed out after ${RUNNING_TIMEOUT_MS}ms (phase=${phase})`); + } + await sleep(2_000); + } + pass("wait-running", `phase=${phase}`); + } + + const acpUrl = `${WS_BASE}/api/agentruns/${runName}/acp`; + + // connect + initialize + session/new + prompt + let sessionId: string; + let liveUpdateCount: number; + { + const sock = await BrowserAcpSocket.open(acpUrl); + pass("ws-connect", acpUrl); + + const init = (await sock.request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + })) as { protocolVersion?: number; agentCapabilities?: { loadSession?: boolean } }; + if (typeof init?.protocolVersion !== "number") { + fail("initialize", `unexpected response: ${JSON.stringify(init)}`); + } + pass( + "initialize", + `protocolVersion=${init.protocolVersion} loadSession=${init.agentCapabilities?.loadSession ?? false}`, + ); + + const created = (await sock.request("session/new", { + cwd: "/workspace", + mcpServers: [], + })) as { sessionId?: string }; + if (!created?.sessionId) fail("session-new", `no sessionId: ${JSON.stringify(created)}`); + sessionId = created.sessionId!; + pass("session-new", sessionId); + + const result = (await sock.request( + "session/prompt", + { + sessionId, + prompt: [{ type: "text", text: "Start the migration analysis." }], + }, + RPC_TIMEOUT_MS, + )) as { stopReason?: string }; + if (result?.stopReason !== "end_turn") { + fail("prompt", `stopReason=${result?.stopReason} (expected end_turn)`); + } + liveUpdateCount = sock.updates.length; + const chunkWithPrompt = sock.updates.find( + (u) => + u.update?.sessionUpdate === "agent_message_chunk" && + JSON.stringify(u.update).includes("Standing prompt"), + ); + if (!chunkWithPrompt) { + fail( + "prompt", + `no agent_message_chunk containing "Standing prompt" in ${liveUpdateCount} updates`, + ); + } + pass("prompt", `stopReason=end_turn, ${liveUpdateCount} updates streamed`); + await sock.close(); + } + + // fresh connection + session/load replay + { + const sock = await BrowserAcpSocket.open(acpUrl); + await sock.request("initialize", { protocolVersion: 1, clientCapabilities: {} }); + await sock.request("session/load", { sessionId, cwd: "/workspace", mcpServers: [] }); + // Replay notifications are sent before the load response resolves, but + // give the event loop a beat in case any frame is still in flight. + await sleep(250); + const replayed = sock.updates.length; + if (replayed === 0) fail("session-load", "no updates replayed"); + pass("session-load", `${replayed} updates replayed (live turn streamed ${liveUpdateCount})`); + await sock.close(); + } + + // delete run + { + const { status } = await fetchJson(`/api/agentruns/${runName}`, { method: "DELETE" }); + if (status !== 204) fail("delete-run", `status=${status}`); + pass("delete-run", runName); + runName = undefined; + } + } catch (err) { + if (!(err instanceof SmokeAbort)) { + failures++; + console.error(`FAIL unexpected — ${err instanceof Error ? err.message : String(err)}`); + } + } finally { + // Best-effort cleanup if we bailed after creating the run. + if (runName) { + await fetchJson(`/api/agentruns/${runName}`, { method: "DELETE" }).catch(() => undefined); + console.log(`(cleanup) deleted ${runName}`); + } + } + + console.log(failures === 0 ? "SMOKE PASS" : `SMOKE FAIL (${failures} failure(s))`); + process.exitCode = failures === 0 ? 0 : 1; +} + +void main(); diff --git a/clients/packages/hub-shim/dev/dial-check.ts b/clients/packages/hub-shim/dev/dial-check.ts new file mode 100644 index 00000000..e6bd93d1 --- /dev/null +++ b/clients/packages/hub-shim/dev/dial-check.ts @@ -0,0 +1,126 @@ +/** + * Deterministic check of the readiness-aware ACP dial (src/acp-dial.ts). + * + * Reproduces the exact failure the fix targets — a pod that accepts the ACP + * connection only AFTER the initial dial — using a local WebSocket server that + * binds late. Also asserts the two boundaries: fail-fast on a non-readiness + * rejection (401), and the control that a single un-retried dial (the old + * behavior) fails immediately. + * + * Run: npx tsx dev/dial-check.ts (no cluster needed) + */ +import { createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import { WebSocket as WsWebSocket, WebSocketServer } from "ws"; +import { connectUpstream, isNotReadyDialError } from "../src/acp-dial.js"; + +let failures = 0; +const pass = (s: string, d?: string) => console.log(`PASS ${s}${d ? ` — ${d}` : ""}`); +const fail = (s: string, d: string) => { + failures++; + console.error(`FAIL ${s} — ${d}`); +}; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Reserve a free localhost port, then release it. */ +async function freePort(): Promise { + const srv = createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const port = (srv.address() as AddressInfo).port; + await new Promise((resolve) => srv.close(() => resolve())); + return port; +} + +async function main(): Promise { + // 1) Late-bind: nothing is listening when the dial starts; the ACP server + // comes up ~1.2s later. The fix must retry through the ECONNREFUSED + // window and open once the server binds. + { + const port = await freePort(); + const target = `ws://127.0.0.1:${port}/acp`; + let wss: WebSocketServer | undefined; + const bindDelayMs = 1_200; + setTimeout(() => { + wss = new WebSocketServer({ port, host: "127.0.0.1" }); + wss.on("connection", (ws) => ws.send("hello")); + }, bindDelayMs); + + const started = Date.now(); + const logs: string[] = []; + try { + const ws = await connectUpstream(target, { + secretKey: "k", + tag: "acp check", + isClientClosed: () => false, + timeoutMs: 20_000, + retryMs: 200, + log: (m) => logs.push(m), + }); + const elapsed = Date.now() - started; + if (ws.readyState !== WsWebSocket.OPEN) fail("late-bind", `socket not OPEN (state=${ws.readyState})`); + else if (elapsed < bindDelayMs) fail("late-bind", `opened too early (${elapsed}ms < ${bindDelayMs}ms bind delay)`); + else if (!logs.some((l) => l.includes("retrying dial"))) fail("late-bind", `no retry was logged: ${JSON.stringify(logs)}`); + else pass("late-bind", `connected after ${elapsed}ms; logs=${JSON.stringify(logs)}`); + ws.terminate(); + } catch (err) { + fail("late-bind", `threw instead of connecting: ${(err as Error).message}`); + } finally { + wss?.close(); + } + } + + // 2) Fail-fast: a 401 handshake rejection is NOT a readiness signal, so the + // dial must surface it immediately, not burn the whole budget retrying. + { + const srv = createServer(); + srv.on("upgrade", (_req, socket) => { + socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + }); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const port = (srv.address() as AddressInfo).port; + const target = `ws://127.0.0.1:${port}/acp`; + + const started = Date.now(); + try { + const ws = await connectUpstream(target, { + secretKey: "k", + tag: "acp check", + isClientClosed: () => false, + timeoutMs: 20_000, + retryMs: 200, + }); + ws.terminate(); + fail("fail-fast", "unexpectedly connected to a 401 endpoint"); + } catch (err) { + const elapsed = Date.now() - started; + if (elapsed > 3_000) fail("fail-fast", `took ${elapsed}ms — looks like it retried a non-readiness error`); + else pass("fail-fast", `rejected in ${elapsed}ms: ${(err as Error).message}`); + } finally { + srv.close(); + } + } + + // 3) Control: a single, un-retried dial to a not-yet-listening port fails + // immediately — the pre-fix behavior that surfaced to the browser as 1011. + { + const port = await freePort(); + const target = `ws://127.0.0.1:${port}/acp`; + const err = await new Promise((resolve) => { + const ws = new WsWebSocket(target); + ws.once("open", () => { + ws.terminate(); + resolve(undefined); + }); + ws.once("error", (e: Error) => resolve(e)); + }); + if (!err) fail("control", "single dial unexpectedly opened"); + else if (!isNotReadyDialError(err)) fail("control", `error not classified as not-ready: ${err.message}`); + else pass("control", `single dial failed as expected (${err.message}) — this is what used to become 1011`); + } + + await sleep(50); + console.log(failures === 0 ? "DIAL-CHECK PASS" : `DIAL-CHECK FAIL (${failures})`); + process.exitCode = failures === 0 ? 0 : 1; +} + +void main(); diff --git a/clients/packages/hub-shim/dev/drop-check.ts b/clients/packages/hub-shim/dev/drop-check.ts new file mode 100644 index 00000000..cc72e537 --- /dev/null +++ b/clients/packages/hub-shim/dev/drop-check.ts @@ -0,0 +1,224 @@ +/** + * Verifies that a dead agent-side connection SURFACES to the browser client + * instead of hanging the session forever — the TEST_DROP scenario. + * + * Two checks: + * A) direct-dial (no cluster): the upstream WebSocket itself must see a + * close/error when the server destroys the TCP connection mid-stream. + * This is the ACP_DIAL=direct path — no tunnel in between. + * B) tunnel E2E (live shim + cluster, like browser-smoke): create a mock + * run, prompt it with TEST_DROP (the mock harness destroys all TCP + * connections mid-turn), and require the browser-side WebSocket to be + * closed with 1011 within a budget. Pre-fix behavior: the port-forward + * tunnel swallowed the pod-side teardown and this hung indefinitely. + * + * Run: npm run drop-check (SHIM_URL overrides base) + * DROP_CHECK_LOCAL_ONLY=1 npm run drop-check (section A only) + */ +import type { Socket } from "node:net"; +import { WebSocketServer } from "ws"; +import { connectUpstream } from "../src/acp-dial.js"; + +const BASE = process.env.SHIM_URL ?? "http://127.0.0.1:7080"; +const WS_BASE = BASE.replace(/^http/, "ws"); +const RUNNING_TIMEOUT_MS = 120_000; +/** + * Ceiling for the drop to surface. The tunnel teardown fix propagates in + * ~milliseconds; the keepalive backstop needs up to 2× ACP_KEEPALIVE_MS + * (default 10s). Anything past this means the hang is back. + */ +const DROP_CLOSE_BUDGET_MS = Number(process.env.DROP_CLOSE_BUDGET_MS ?? 25_000); + +let failures = 0; +const pass = (s: string, d?: string) => console.log(`PASS ${s}${d ? ` — ${d}` : ""}`); +const fail = (s: string, d: string) => { + failures++; + console.error(`FAIL ${s} — ${d}`); +}; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------- A) direct-dial teardown + +async function checkDirectDial(): Promise { + const rawSockets = new Set(); + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wss.once("listening", resolve)); + const port = (wss.address() as { port: number }).port; + wss.on("connection", (ws, req) => { + rawSockets.add(req.socket); + ws.send("hello"); + }); + + try { + const ws = await connectUpstream(`ws://127.0.0.1:${port}/acp`, { + secretKey: "k", + tag: "drop check", + isClientClosed: () => false, + timeoutMs: 5_000, + retryMs: 100, + }); + const closed = new Promise((resolve) => { + ws.on("close", (code: number) => resolve(`close code=${code}`)); + ws.on("error", (err: Error) => resolve(`error ${err.message}`)); + }); + await sleep(100); // let the hello frame land, mirror an established session + for (const s of rawSockets) s.destroy(); // what TEST_DROP does pod-side + const started = Date.now(); + const outcome = await Promise.race([closed, sleep(3_000).then(() => undefined)]); + if (!outcome) { + fail("direct-dial", "destroyed server socket never surfaced on the client (3s)"); + ws.terminate(); + } else { + pass("direct-dial", `surfaced as ${outcome} after ${Date.now() - started}ms`); + } + } catch (err) { + fail("direct-dial", `dial failed: ${(err as Error).message}`); + } finally { + wss.close(); + } +} + +// ------------------------------------------------- B) tunnel E2E via shim + +async function fetchJson( + path: string, + init?: RequestInit, +): Promise<{ status: number; body: unknown }> { + const res = await fetch(`${BASE}${path}`, init); + const text = await res.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = text; + } + return { status: res.status, body }; +} + +type RunView = { metadata?: { name?: string }; status?: { phase?: string } }; + +/** Send one JSON-RPC request and await its response frame. */ +function rpc(ws: WebSocket, id: number, method: string, params: unknown): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${method} timed out (30s)`)), 30_000); + const onMessage = (ev: MessageEvent) => { + void (async () => { + const text = typeof ev.data === "string" ? ev.data : await (ev.data as Blob).text(); + const msg = JSON.parse(text) as { id?: number; result?: unknown; error?: { message: string } }; + if (msg.id !== id) return; + ws.removeEventListener("message", onMessage); + clearTimeout(timer); + if (msg.error) reject(new Error(`${method}: ${msg.error.message}`)); + else resolve(msg.result); + })(); + }; + ws.addEventListener("message", onMessage); + ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); + }); +} + +async function checkTunnelDrop(): Promise { + const health = await fetch(`${BASE}/healthz`).catch(() => undefined); + if (!health || health.status !== 200) { + fail("tunnel-drop", `shim not reachable at ${BASE} — start it (hack/demo-up.sh) or set DROP_CHECK_LOCAL_ONLY=1`); + return; + } + + let runName: string | undefined; + try { + const { status, body } = await fetchJson("/api/agentruns", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agentRef: "migration-analyzer", + params: { + repository: "https://github.com/konveyor-ecosystem/coolstore", + branch: "main", + }, + instructions: "drop-check", + }), + }); + const created = body as RunView; + if (status !== 201 || !created?.metadata?.name) { + fail("tunnel-drop", `create run: status=${status} body=${JSON.stringify(body)}`); + return; + } + runName = created.metadata.name; + + const deadline = Date.now() + RUNNING_TIMEOUT_MS; + for (;;) { + const run = (await fetchJson(`/api/agentruns/${runName}`)).body as RunView; + const phase = run?.status?.phase ?? "unset"; + if (phase === "Running") break; + if (phase === "Failed" || Date.now() > deadline) { + fail("tunnel-drop", `run never Running (phase=${phase})`); + return; + } + await sleep(2_000); + } + + const ws = new WebSocket(`${WS_BASE}/api/agentruns/${runName}/acp`); + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("ws failed to open")), { once: true }); + }); + const closed = new Promise((resolve) => { + ws.addEventListener("close", resolve, { once: true }); + }); + + await rpc(ws, 1, "initialize", { protocolVersion: 1, clientCapabilities: {} }); + const session = (await rpc(ws, 2, "session/new", { cwd: "/workspace", mcpServers: [] })) as { + sessionId?: string; + }; + if (!session?.sessionId) { + fail("tunnel-drop", `session/new returned ${JSON.stringify(session)}`); + return; + } + + // The response to this prompt never arrives — the harness kills every + // TCP connection mid-turn. What MUST arrive is the close event. + ws.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "session/prompt", + params: { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "connectivity drill: TEST_DROP" }], + }, + }), + ); + const started = Date.now(); + const outcome = await Promise.race([ + closed, + sleep(DROP_CLOSE_BUDGET_MS).then(() => undefined), + ]); + if (!outcome) { + fail( + "tunnel-drop", + `browser socket still open ${DROP_CLOSE_BUDGET_MS}ms after TEST_DROP — the pre-fix hang`, + ); + ws.close(); + } else if (outcome.code !== 1011) { + fail("tunnel-drop", `closed with code=${outcome.code} (expected 1011) reason=${outcome.reason}`); + } else { + pass( + "tunnel-drop", + `client closed code=1011 reason="${outcome.reason}" ${Date.now() - started}ms after prompt`, + ); + } + } finally { + if (runName) { + await fetchJson(`/api/agentruns/${runName}`, { method: "DELETE" }).catch(() => undefined); + } + } +} + +async function main(): Promise { + await checkDirectDial(); + if (process.env.DROP_CHECK_LOCAL_ONLY !== "1") await checkTunnelDrop(); + console.log(failures === 0 ? "DROP-CHECK PASS" : `DROP-CHECK FAIL (${failures})`); + process.exitCode = failures === 0 ? 0 : 1; +} + +void main(); diff --git a/clients/packages/hub-shim/package-lock.json b/clients/packages/hub-shim/package-lock.json new file mode 100644 index 00000000..af143754 --- /dev/null +++ b/clients/packages/hub-shim/package-lock.json @@ -0,0 +1,1380 @@ +{ + "name": "@konveyor/hub-shim", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@konveyor/hub-shim", + "version": "0.1.0", + "dependencies": { + "@kubernetes/client-node": "^1.4.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "@types/ws": "^8.18.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@kubernetes/client-node": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", + "license": "Apache-2.0", + "dependencies": { + "@types/js-yaml": "^4.0.1", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", + "@types/stream-buffers": "^3.0.3", + "form-data": "^4.0.0", + "hpagent": "^1.2.0", + "isomorphic-ws": "^5.0.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "node-fetch": "^2.7.0", + "openid-client": "^6.1.3", + "rfc4648": "^1.3.0", + "socks-proxy-agent": "^8.0.4", + "stream-buffers": "^3.0.2", + "tar-fs": "^3.0.9", + "ws": "^8.18.2" + } + }, + "node_modules/@kubernetes/client-node/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/stream-buffers": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.3.tgz", + "integrity": "sha512-xRgplks8SvcKkdlv2M6Z2LZmRsmqd+x0nXXGXeMEjwdibj1HSDrlnqBRLeYdMvsgCox7Bq0e+DHwfczOfsn6IA==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz", + "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/clients/packages/hub-shim/package.json b/clients/packages/hub-shim/package.json new file mode 100644 index 00000000..c5f86dd5 --- /dev/null +++ b/clients/packages/hub-shim/package.json @@ -0,0 +1,24 @@ +{ + "name": "@konveyor/hub-shim", + "version": "0.1.0", + "private": true, + "description": "Localhost dev gateway serving the SHIM HTTP API v1 — stands in for the future Konveyor Hub passthrough proxy so browser UIs can drive the real agentic-controller.", + "type": "module", + "scripts": { + "start": "tsx src/server.ts", + "typecheck": "tsc --noEmit", + "smoke": "tsx dev/browser-smoke.ts", + "dial-check": "tsx dev/dial-check.ts", + "drop-check": "tsx dev/drop-check.ts" + }, + "dependencies": { + "@kubernetes/client-node": "^1.4.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "@types/ws": "^8.18.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/clients/packages/hub-shim/src/acp-dial.ts b/clients/packages/hub-shim/src/acp-dial.ts new file mode 100644 index 00000000..89f285d8 --- /dev/null +++ b/clients/packages/hub-shim/src/acp-dial.ts @@ -0,0 +1,107 @@ +/** + * Readiness-aware ACP dial. + * + * waitForAcpEndpoint gates on the AgentRun's phase=Running, but a sandbox pod + * can be Running — and, under the real agentic-controller, Ready, since that + * controller declares no readiness probe on the harness container — before the + * harness process has bound :4000. Dialing into that window resets the socket, + * which Node surfaces as "socket hang up". Treating the ACP dial itself as the + * readiness signal (retry until it opens) closes the race regardless of which + * controller provisioned the pod, mirroring the simulator pod's own + * tcpSocket:4000 probe. + */ +import { WebSocket as WsWebSocket } from "ws"; + +export const ACP_READY_TIMEOUT_MS = Number(process.env.ACP_READY_TIMEOUT_MS ?? 45_000); +export const ACP_READY_RETRY_MS = Number(process.env.ACP_READY_RETRY_MS ?? 300); +/** + * Per-attempt open cap. A dial whose port-forward established its stream to a + * pod that is not yet listening on :4000 can hang without ever firing + * open/error/close; since the readiness deadline is only enforced BETWEEN + * attempts, one such stall would block the whole budget. Abort the attempt + * after this long and retry (a healthy ACP dial opens in well under a second). + */ +export const ACP_DIAL_ATTEMPT_MS = Number(process.env.ACP_DIAL_ATTEMPT_MS ?? 3_000); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * A dial failure meaning "the ACP server isn't listening yet" (retry) rather + * than a real rejection like an auth 401 (surface immediately). Until the + * harness binds :4000 the tunnel/direct dial refuses or resets mid-handshake: + * ECONNREFUSED, ECONNRESET, or Node's "socket hang up". + */ +export function isNotReadyDialError(err: unknown): boolean { + const code = (err as { code?: string })?.code; + if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE") return true; + const message = err instanceof Error ? err.message : String(err); + return /socket hang up|ECONNREFUSED|ECONNRESET|EPIPE|before open/i.test(message); +} + +export interface ConnectUpstreamOptions { + /** Injected as the X-Secret-Key header the browser cannot set. */ + secretKey: string; + /** Log prefix identifying the run, e.g. "acp ui-abc". */ + tag: string; + /** Abandon the dial if the browser client has gone away. */ + isClientClosed: () => boolean; + timeoutMs?: number; + retryMs?: number; + log?: (msg: string) => void; +} + +/** + * Opens the upstream ACP WebSocket, retrying while the dial fails with a + * not-ready signal until it opens or the readiness budget expires. Resolves + * with an OPEN socket (no listeners attached). Throws the last dial error if + * the endpoint never comes up, or immediately if the failure is not + * readiness-related (e.g. a 401 handshake rejection — retrying is pointless). + */ +export async function connectUpstream( + target: string, + opts: ConnectUpstreamOptions, +): Promise { + const { secretKey, tag, isClientClosed } = opts; + const timeoutMs = opts.timeoutMs ?? ACP_READY_TIMEOUT_MS; + const retryMs = opts.retryMs ?? ACP_READY_RETRY_MS; + const log = opts.log ?? (() => {}); + const deadline = Date.now() + timeoutMs; + let attempt = 0; + for (;;) { + if (isClientClosed()) throw new Error("browser client closed before ACP endpoint was ready"); + attempt++; + const ws = new WsWebSocket(target, { headers: { "X-Secret-Key": secretKey } }); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("dial attempt stalled before open")), + ACP_DIAL_ATTEMPT_MS, + ); + ws.once("open", () => { + clearTimeout(timer); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + ws.once("close", () => { + clearTimeout(timer); + reject(new Error("upstream closed before open")); + }); + }); + if (attempt > 1) log(`${tag} upstream ready after ${attempt} attempts`); + return ws; + } catch (err) { + ws.removeAllListeners(); + // terminate() on a still-CONNECTING socket (the per-attempt stall path) + // emits 'error' asynchronously; without a listener that is an unhandled + // 'error' event that crashes the process. Absorb it. + ws.on("error", () => {}); + ws.terminate(); + if (!isNotReadyDialError(err) || Date.now() + retryMs >= deadline) throw err; + if (attempt === 1) log(`${tag} ACP not listening yet, retrying dial…`); + await sleep(retryMs); + } + } +} diff --git a/clients/packages/hub-shim/src/defaults.ts b/clients/packages/hub-shim/src/defaults.ts new file mode 100644 index 00000000..fb3d043e --- /dev/null +++ b/clients/packages/hub-shim/src/defaults.ts @@ -0,0 +1,257 @@ +/** + * The default managed resource set POST /api/defaults seeds — the API-first + * replacement for demo-up.sh's kubectl seeding. + * + * Mirrors the upstream harness testbed (hack/harness-test on PR #53): one + * LLMProvider named as a goose provider id, the four migration skill cards, + * three stage Agents on agent-java, and the java-ee-to-quarkus playbook. + * On top of that, the PatternFly demo set: a patternfly-migration domain + * card paired with the same stage cards on agent-nodejs, as a second + * playbook — proof the skill model generalizes beyond Java with zero code. + * + * Everything is labeled konveyor.io/managed=true (the upstream seeds are + * not — filed as #53 feedback item 3), so it all shows up in Konveyor UIs. + * + * Seeding is create-only: a name that already exists is reported "exists" + * and left untouched, so local edits survive a re-seed. + */ +import { + IMAGE_CATALOG_CONFIGMAP, + IMAGE_CATALOG_KEY, + MANAGED_LABEL, + type AgentImage, +} from "../../agentic-client/src/contract/index.js"; +import { API_VERSION, PLURALS } from "../../agentrun-client/src/types.js"; + +/** + * The upstream agent-image hierarchy (images/README.md on PR #53). Serves + * as the built-in catalog when the ConfigMap is absent, and as the content + * seeded INTO that ConfigMap by loadDefaults. The :dev tag matches what the + * image build tooling stamps today; CI-published tags land post-#53. + */ +export const DEFAULT_IMAGE_CATALOG: AgentImage[] = [ + { + image: "quay.io/konveyor/agent-base:dev", + displayName: "Agent base", + notes: "UBI 10 + goose CLI + git + Python 3 + graphify + migration-harness binary", + }, + { + image: "quay.io/konveyor/agent-java:dev", + displayName: "Java agent", + language: "java", + base: "quay.io/konveyor/agent-base:dev", + notes: "adds JDK 21 and Maven", + }, + { + image: "quay.io/konveyor/agent-go:dev", + displayName: "Go agent", + language: "go", + base: "quay.io/konveyor/agent-base:dev", + notes: "adds the Go toolchain", + }, + { + image: "quay.io/konveyor/agent-csharp:dev", + displayName: "C# agent", + language: "csharp", + base: "quay.io/konveyor/agent-base:dev", + notes: "adds the .NET SDK", + }, + { + image: "quay.io/konveyor/agent-nodejs:dev", + displayName: "Node.js agent", + language: "nodejs", + base: "quay.io/konveyor/agent-base:dev", + notes: "adds Node.js and npm", + }, +]; + +const MANAGED = { [MANAGED_LABEL]: "true" }; + +/** The catalog ConfigMap body loadDefaults creates (core/v1, not a CR). */ +export function imageCatalogConfigMap(namespace: string): object { + return { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: IMAGE_CATALOG_CONFIGMAP, namespace, labels: MANAGED }, + data: { [IMAGE_CATALOG_KEY]: JSON.stringify(DEFAULT_IMAGE_CATALOG, null, 2) }, + }; +} + +export interface DefaultResource { + plural: string; + kind: string; + name: string; + body: Record; +} + +function resource( + plural: string, + kind: string, + name: string, + spec: Record, +): DefaultResource { + return { + plural, + kind, + name, + body: { apiVersion: API_VERSION, kind, metadata: { name, labels: MANAGED }, spec }, + }; +} + +const skillCard = (name: string, displayName: string, description: string, tags: string[]) => + resource(PLURALS.SkillCard, "SkillCard", name, { + image: `quay.io/konveyor/skills:${name}`, + displayName, + version: "1.0.0", + description, + type: "skill", + tags, + }); + +const stageAgent = ( + name: string, + image: string, + cards: string[], + params: { name: string; type: string; default: string }[], +) => + resource(PLURALS.Agent, "Agent", name, { + image, + providers: [{ ref: SEED_PROVIDER }], + skillCards: cards.map((ref) => ({ ref })), + params, + }); + +/** + * Provider name maps VERBATIM to a goose provider id (lowercased, "-" -> + * "_") — gcp-vertex-ai -> gcp_vertex_ai. Its credential Secret + * (vertex-credentials) is not seeded: credentials never ride this path. + */ +const SEED_PROVIDER = "gcp-vertex-ai"; + +const MAX_TURNS = { name: "max_turns", type: "number", default: "200" }; + +const JAVA_IMAGE = "quay.io/konveyor/agent-java:dev"; +const NODEJS_IMAGE = "quay.io/konveyor/agent-nodejs:dev"; + +/** + * The full seed set, in create order (providers before the agents that + * reference them; agents before playbooks). NOTE the deliberate name split + * upstream chose: the PLAYBOOK is "java-ee-to-quarkus", the SKILLCARD is + * "javaee-to-quarkus". + */ +export function defaultResources(): DefaultResource[] { + return [ + resource(PLURALS.LLMProvider, "LLMProvider", SEED_PROVIDER, { + endpoint: "global-aiplatform.googleapis.com", + credentialRef: { secretName: "vertex-credentials" }, + models: [{ name: "claude-sonnet-4-5", contextWindow: 200000, tier: "premium" }], + }), + + // Stage skills — language-agnostic: plan reads any project, execute + // works PLAN.md, verify runs whatever build command PLAN.md names. + skillCard("plan", "Plan Stage", "Reads a project and produces PLAN.md with migration steps.", [ + "migration", + "planning", + ]), + skillCard( + "execute", + "Execute Stage", + "Reads PLAN.md and executes each migration step sequentially.", + ["migration", "execution"], + ), + skillCard( + "verify", + "Verify Stage", + "Runs the build command, fixes errors, and iterates until the build passes.", + ["migration", "verification"], + ), + + // Domain skills — one per migration, paired with the stage skills. + skillCard( + "javaee-to-quarkus", + "Java EE to Quarkus Migration", + "Migrates Java EE 7/8 applications (WebLogic, JBoss, WildFly) to Quarkus 3. " + + "Covers EJB-to-CDI, JMS/MDB-to-SmallRye, WAR-to-JAR, persistence.xml replacement, " + + "JNDI removal, and server lifecycle replacement.", + ["java", "javaee", "quarkus", "migration"], + ), + skillCard( + "patternfly-migration", + "PatternFly 5 to 6 Migration", + "Migrates React applications from PatternFly 5 to PatternFly 6: design tokens, " + + "Text-to-Content, removed components, pf-v6 class renames, and codemod-driven " + + "API updates.", + ["patternfly", "react", "nodejs", "migration"], + ), + + stageAgent("migration-plan-agent", JAVA_IMAGE, ["plan", "javaee-to-quarkus"], [MAX_TURNS]), + stageAgent("migration-execute-agent", JAVA_IMAGE, ["execute", "javaee-to-quarkus"], [MAX_TURNS]), + stageAgent( + "migration-verify-agent", + JAVA_IMAGE, + ["verify", "javaee-to-quarkus"], + [MAX_TURNS, { name: "max_fix_iterations", type: "number", default: "3" }], + ), + + stageAgent("patternfly-plan-agent", NODEJS_IMAGE, ["plan", "patternfly-migration"], [MAX_TURNS]), + stageAgent( + "patternfly-execute-agent", + NODEJS_IMAGE, + ["execute", "patternfly-migration"], + [MAX_TURNS], + ), + stageAgent( + "patternfly-verify-agent", + NODEJS_IMAGE, + ["verify", "patternfly-migration"], + [MAX_TURNS, { name: "max_fix_iterations", type: "number", default: "3" }], + ), + + resource(PLURALS.AgentPlaybook, "AgentPlaybook", "java-ee-to-quarkus", { + guide: "Migrate a Java EE application to Quarkus 3", + stages: [ + { + name: "plan", + agentRef: "migration-plan-agent", + instructions: + "Analyze the project structure using graphify and produce PLAN.md with migration steps", + }, + { + name: "execute", + agentRef: "migration-execute-agent", + instructions: + "Execute each step in PLAN.md to migrate the code from Java EE to Quarkus", + }, + { + name: "verify", + agentRef: "migration-verify-agent", + instructions: "Run mvn clean compile, fix any compilation errors, then run tests", + }, + ], + }), + + resource(PLURALS.AgentPlaybook, "AgentPlaybook", "patternfly-migration", { + guide: "Migrate a React application from PatternFly 5 to PatternFly 6", + stages: [ + { + name: "plan", + agentRef: "patternfly-plan-agent", + instructions: + "Analyze the project structure and produce PLAN.md with PatternFly 6 migration steps", + }, + { + name: "execute", + agentRef: "patternfly-execute-agent", + instructions: + "Execute each step in PLAN.md to migrate the UI from PatternFly 5 to PatternFly 6", + }, + { + name: "verify", + agentRef: "patternfly-verify-agent", + instructions: + "Run npm install and npm run build, fix any build errors, then run tests if present", + }, + ], + }), + ]; +} diff --git a/clients/packages/hub-shim/src/server.ts b/clients/packages/hub-shim/src/server.ts new file mode 100644 index 00000000..d922e862 --- /dev/null +++ b/clients/packages/hub-shim/src/server.ts @@ -0,0 +1,1665 @@ +/** + * hub-shim — localhost gateway serving the SHIM HTTP API v1. + * + * Stands in for the future Konveyor Hub passthrough proxy so browser UIs + * can drive the real agentic-controller today. Browsers cannot set the + * X-Secret-Key upgrade header nor reach the sandbox pod; this shim owns + * both: it resolves a run's ACP endpoint (pod by status.sandboxName, key + * from status.secretKeyRef), reaches the pod (port-forward tunnel on a + * laptop, direct service-DNS dial in-cluster), and pipes WebSocket frames + * between the browser and the pod's :4000/acp. + * + * Routes: + * GET /healthz -> 200 "ok" + * GET /api/applications -> 200 Application[] (mock inventory) + * GET /api/agents[/:name] -> 200 Agent[] | Agent | 404 + * (list filtered: konveyor.io/managed=true) + * GET /api/llmproviders[/:name] -> 200 LLMProvider[] | LLMProvider | 404 + * GET /api/skillcards[/:name] -> 200 SkillCard[] | SkillCard | 404 + * GET /api/skillcollections[/:name]-> 200 SkillCollection[] | SkillCollection | 404 + * GET /api/agentruns -> 200 AgentRun[] + * POST /api/agentruns -> 201 AgentRun (generateName "ui-"; + * applicationRef -> Hub coordinates + + * TARGET_BRANCH injected as spec.env) + * GET /api/agentruns/:name -> 200 AgentRun | 404 + * DELETE /api/agentruns/:name -> 204 | 404 + * WS /api/agentruns/:name/acp -> bidirectional pipe to the pod + * GET /api/agentplaybooks[/:name] -> 200 AgentPlaybook[] | AgentPlaybook | 404 + * GET /api/agentplaybookruns -> 200 AgentPlaybookRun[] + * POST /api/agentplaybookruns -> 201 AgentPlaybookRun (generateName + * "ui-"; models resolved vs the union + * of stage Agents; applicationRef -> + * the same env injection, forwarded + * to every stage) + * GET /api/agentplaybookruns/:name -> 200 AgentPlaybookRun | 404 + * DELETE /api/agentplaybookruns/:name -> 204 | 404 (stage runs cascade) + * GET /api/images -> 200 AgentImage[] (agent-image + * catalog ConfigMap, else the + * built-in hierarchy) + * POST /api/defaults -> 200 SeedResult[] (seeds the + * default managed resource set; + * idempotent, create-only) + * + * Catalog writes (the Hub R1 write proposal) — for each of agents, + * skillcards, skillcollections, agentplaybooks: + * POST /api/ -> 201 CR body {name, spec}; + * named create, konveyor.io/managed + * stamped; 409 when the name exists + * PUT /api//:name -> 200 CR body {spec}; replaces + * the spec, preserves metadata, + * stamps the managed label (editing + * ADOPTS an unlabeled resource); + * 404 absent, 409 write conflict + * DELETE /api//:name -> 204 | 404 + * Thin passthrough by design (issue-22 placement): CRD schema/CEL failures + * surface as the apiserver's 4xx + message, not shim-side re-validation. + * List filtering: agents, skillcards, skillcollections, agentplaybooks are + * managed-label filtered; llmproviders (admin-owned) and both run kinds + * (stage runs are controller-created and unlabeled; other callers' runs + * must stay visible) are NOT. + * + * The Konveyor-aware harness pulls the application's repo, decrypted git + * identity, and analysis from the Hub itself (keyed by the injected env) — + * the ADR 0005 param/credential-source resolution formerly performed here is + * RETIRED for the platform path. + * + * No auth on the shim itself — localhost dev tool only. CORS `*` on /api/*. + */ +import * as http from "node:http"; +import * as k8s from "@kubernetes/client-node"; +import { WebSocket as WsWebSocket, WebSocketServer, type RawData } from "ws"; +// Reused from the sibling POC package (tsx resolves cross-package TS imports). +// kube.ts implements waitForAcpEndpoint with the verified real-controller +// semantics: pod resolved by status.sandboxName (NOT labels), secret key read +// from "secret-key" / "ACP_SECRET_KEY" / sole-entry fallback. +import { AgentRunClient } from "../../agentrun-client/src/kube.js"; +import { openTunnel, type Tunnel } from "../../agentrun-client/src/portforward.js"; +import { connectUpstream } from "./acp-dial.js"; +import { + API_VERSION, + GROUP, + VERSION, + PLURALS, + type Agent, + type AgentPlaybook, + type AgentPlaybookRun, + type AgentPlaybookRunSpec, + type AgentRun, + type AgentRunModelSelection, + type AgentRunSpec, + type EnvFromSource, + type EnvVar, + type LLMProvider, +} from "../../agentrun-client/src/types.js"; +import { + IMAGE_CATALOG_CONFIGMAP, + IMAGE_CATALOG_KEY, + MANAGED_LABEL, + RESOURCE_NAME_MAX, + RESOURCE_NAME_PATTERN, + RUN_ENV, + defaultTargetBranch, + invalidTargetBranchReason, + type AgentImage, + type Application, + type SeedResult, +} from "../../agentic-client/src/contract/index.js"; +import { DEFAULT_IMAGE_CATALOG, defaultResources, imageCatalogConfigMap } from "./defaults.js"; + +const PORT = Number(process.env.PORT ?? 7080); +const HOST = process.env.HOST ?? "127.0.0.1"; +const NAMESPACE = process.env.NAMESPACE ?? "konveyor-agents"; +const ACP_RESOLVE_TIMEOUT_MS = 60_000; +/** + * Upstream liveness probe interval. The port-forward tunnel can die without + * the upstream WebSocket ever seeing a close/error (the tunnel fix in + * portforward.ts covers the known path, but any silent-death mode — wedged + * API-server stream, network partition to the cluster — leaves the bridge + * piping into a void). Ping the upstream every interval; a peer that neither + * pongs nor sends any frame for a full interval is declared dead and the + * browser client is closed with 1011 instead of hanging forever. 0 disables. + */ +const ACP_KEEPALIVE_MS = Number(process.env.ACP_KEEPALIVE_MS ?? 10_000); + +/** + * How to reach a sandbox pod's :4000. + * - "tunnel": Kubernetes port-forward (the laptop-dev substitute). + * - "direct": dial the run's headless-Service DNS name — the in-cluster + * path, and what the real Hub proxy will do. + * Auto-detect: in-cluster (serviceaccount env present) means direct. + */ +const ACP_DIAL = + process.env.ACP_DIAL === "direct" || process.env.ACP_DIAL === "tunnel" + ? process.env.ACP_DIAL + : process.env.KUBERNETES_SERVICE_HOST + ? "direct" + : "tunnel"; + +const log = (msg: string) => console.log(`[hub-shim] ${msg}`); +const warn = (msg: string) => console.warn(`[hub-shim] ${msg}`); + +// runClient owns its own KubeConfig (loadFromDefault: respects $KUBECONFIG). +// A second KubeConfig is loaded from this package's @kubernetes/client-node +// copy for list calls — the two copies' classes have private members, so +// instances must never cross between them. +const runClient = new AgentRunClient({ namespace: NAMESPACE }); +const kc = new k8s.KubeConfig(); +kc.loadFromDefault(); +const custom = kc.makeApiClient(k8s.CustomObjectsApi); +const core = kc.makeApiClient(k8s.CoreV1Api); + +async function listCustom( + plural: string, + kind: string, + labelSelector?: string, +): Promise { + const res = (await custom.listNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural, + labelSelector, + })) as { items?: T[] }; + // List items omit apiVersion/kind; restore them so clients get full CRs. + return (res.items ?? []).map((item) => ({ apiVersion: API_VERSION, kind, ...item })); +} + +async function getCustom(plural: string, kind: string, name: string): Promise { + const obj = (await custom.getNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural, + name, + })) as Record; + return { apiVersion: API_VERSION, kind, ...obj }; +} + +/** Resources served read-only as full CRs: list + get by name. */ +const READ_ONLY: Record = { + [PLURALS.Agent]: "Agent", + [PLURALS.AgentPlaybook]: "AgentPlaybook", + [PLURALS.AgentPlaybookRun]: "AgentPlaybookRun", + [PLURALS.LLMProvider]: "LLMProvider", + [PLURALS.SkillCard]: "SkillCard", + [PLURALS.SkillCollection]: "SkillCollection", +}; + +/** + * Konveyor UIs only see catalog resources that opt into platform management + * (everything the UI creates is stamped). Deliberately unfiltered: + * llmproviders (cluster-admin-owned, never UI-created) and both run kinds + * (stage AgentRuns are controller-created without the label, and runs from + * other callers — RHDH, kubectl — must stay visible). Get-by-name is never + * filtered, so unlabeled stage agents remain introspectable. + */ +const LIST_LABEL_SELECTORS: Record = { + [PLURALS.Agent]: `${MANAGED_LABEL}=true`, + [PLURALS.SkillCard]: `${MANAGED_LABEL}=true`, + [PLURALS.SkillCollection]: `${MANAGED_LABEL}=true`, + [PLURALS.AgentPlaybook]: `${MANAGED_LABEL}=true`, +}; + +/** + * Resources the catalog write routes manage (POST/PUT/DELETE). Run kinds are + * NOT here — their write paths (generateName, env/model injection, cascade + * semantics) are bespoke and live in handleApi directly. + */ +const WRITABLE: Record = { + [PLURALS.Agent]: "Agent", + [PLURALS.SkillCard]: "SkillCard", + [PLURALS.SkillCollection]: "SkillCollection", + [PLURALS.AgentPlaybook]: "AgentPlaybook", +}; + +/** + * Real Konveyor Hub REST base. In-cluster this is the Hub service DNS + * (http://tackle2-hub..svc:8080); on a laptop, a port-forward or + * NodePort. When unset/unreachable the shim falls back to STUB_APPLICATIONS + * so it still runs offline. This is the production-shaped knob: the real + * Hub-proxy reads its own Application table; the shim reads it over HTTP. + */ +const HUB_URL = process.env.HUB_URL?.replace(/\/+$/, ""); + +/** + * Hub coordinates injected into run pods (RUN_ENV). The harness dials the + * Hub FROM THE SANDBOX POD, so on a laptop — where HUB_URL is typically a + * localhost port-forward the pod cannot reach — set RUN_HUB_BASE_URL to the + * in-cluster service DNS (e.g. http://tackle2-hub.konveyor-tackle.svc:8080). + * Defaults to HUB_URL, which is correct when the shim itself runs in-cluster. + */ +const RUN_HUB_BASE_URL = process.env.RUN_HUB_BASE_URL?.replace(/\/+$/, "") ?? HUB_URL; + +/** + * Bearer token for the Hub. Used on the shim's own inventory reads AND + * delivered to run pods as HUB_TOKEN (via a Secret-backed valueFrom, never + * plaintext in the CR). Optional only against an UNAUTHENTICATED Hub — repo + * visibility is irrelevant, since the harness always resolves the + * application through the Hub before touching git. + */ +const HUB_TOKEN = process.env.HUB_TOKEN; + +/** + * Offline fallback when HUB_URL is unset or the Hub is unreachable. The id + * is numeric because the harness's ParseAppID requires a uint string — but a + * run created against the stub still needs a REAL Hub to resolve app 1, so + * stub-mode runs are only useful for agents that ignore the application + * (e.g. the mock fixture). + */ +const STUB_APPLICATIONS: Application[] = [ + { + id: "1", + name: "Coolstore (stub — Hub unavailable)", + repository: { url: "https://github.com/konveyor-ecosystem/coolstore.git", branch: "main" }, + }, +]; + +interface HubApp { + id: number; + name: string; + repository?: { url?: string; branch?: string }; + identities?: { id: number; name?: string }[]; +} +interface HubIdentity { + id: number; + name: string; + kind: string; +} + +async function hubGet(path: string): Promise { + const res = await fetch(`${HUB_URL}/${path}`, { + headers: { + accept: "application/json", + ...(HUB_TOKEN ? { authorization: `Bearer ${HUB_TOKEN}` } : {}), + }, + }); + if (!res.ok) throw new Error(`Hub GET /${path} -> HTTP ${res.status}`); + return (await res.json()) as T; +} + +/** + * The platform's application inventory. Reads real Hub Applications and maps + * them to the client Application shape: repository straight from Hub; the + * source-control Identity carried as a reference (identity.name) plus its + * bridged Secret when one exists. Falls back to STUB_APPLICATIONS offline. + */ +/** Where the inventory came from — surfaced to the UI so "real vs stub" is visible. */ +interface Inventory { + source: "hub" | "stub"; + endpoint: string; + applications: Application[]; +} + +async function getApplications(): Promise { + if (!HUB_URL) { + return { source: "stub", endpoint: "offline stub (HUB_URL unset)", applications: STUB_APPLICATIONS }; + } + try { + const [apps, identities] = await Promise.all([ + hubGet("applications"), + hubGet("identities"), + ]); + const sourceKind = new Map(identities.map((i) => [i.id, i.kind])); + const applications = apps.map((a): Application => { + const srcRef = (a.identities ?? []).find((r) => sourceKind.get(r.id) === "source"); + const idName = srcRef?.name; + return { + id: String(a.id), + name: a.name, + repository: a.repository?.url + ? { url: a.repository.url, branch: a.repository.branch } + : undefined, + identity: idName ? { name: idName } : undefined, + }; + }); + return { source: "hub", endpoint: HUB_URL, applications }; + } catch (err) { + warn(`Hub inventory unavailable (${errorMessage(err)}); using offline stub`); + return { source: "stub", endpoint: "offline stub (Hub unreachable)", applications: STUB_APPLICATIONS }; + } +} + +// ---------------------------------------------------------------- HTTP api + +/** + * A fault attributable to the caller (-> 400). Everything else — including + * apiserver transport failures, which carry a STRING `code` like + * "ECONNREFUSED" and so are invisible to k8sStatusCode — must bubble to the + * top-level handler and become a 5xx. Never infer "client fault" from the + * absence of a numeric status code. + */ +class BadRequestError extends Error {} + +// Explicitly typed so TS control-flow analysis treats a call as unreachable +// past this point (narrowing after `if (!x) badRequest(...)`). +const badRequest: (message: string) => never = (message) => { + throw new BadRequestError(message); +}; + +function k8sStatusCode(err: unknown): number | undefined { + if (err && typeof err === "object" && "code" in err) { + const code = (err as { code: unknown }).code; + if (typeof code === "number" && code >= 400 && code <= 599) return code; + } + return undefined; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function sendJson( + res: http.ServerResponse, + status: number, + body: unknown, + extraHeaders?: Record, +): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + ...extraHeaders, + }); + res.end(payload); +} + +function sendError(res: http.ServerResponse, status: number, message: string): void { + sendJson(res, status, { error: message }); +} + +async function readJsonBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buf = chunk as Buffer; + size += buf.length; + if (size > 1_048_576) badRequest("request body too large (max 1 MiB)"); + chunks.push(buf); + } + const text = Buffer.concat(chunks).toString("utf8"); + if (!text.trim()) badRequest("request body is empty; expected JSON"); + try { + return JSON.parse(text); + } catch { + badRequest("request body is not valid JSON"); + } +} + +/** + * The apiserver's own Status message from a @kubernetes/client-node error + * (CEL/schema rejections, AlreadyExists, Conflict) — far more actionable + * than the generic HTTP-code message the client wraps around it. + */ +function k8sMessage(err: unknown): string { + if (err && typeof err === "object" && "body" in err) { + const body = (err as { body: unknown }).body; + if (typeof body === "string") { + try { + const status = JSON.parse(body) as { message?: unknown }; + if (typeof status.message === "string" && status.message) return status.message; + } catch { + if (body.trim()) return body.slice(0, 500); + } + } else if (body && typeof body === "object") { + const message = (body as { message?: unknown }).message; + if (typeof message === "string" && message) return message; + } + } + return errorMessage(err); +} + +// ----------------------------------------------------------- catalog writes + +interface SaveResourceBody { + name?: string; + spec: object; +} + +/** + * Validates a catalog write body: {name, spec} on POST, {spec} on PUT (a + * body name is tolerated only when it matches the path). Spec contents are + * NOT validated here — the CRD schema + CEL rules on the apiserver are the + * single source of truth, and their messages pass through (k8sMessage). + */ +function parseSaveBody(raw: unknown, pathName: string | undefined): SaveResourceBody { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + badRequest(pathName ? "body must be a JSON object: {spec}" : "body must be a JSON object: {name, spec}"); + } + const body = raw as Record; + let name: string | undefined = pathName; + if (pathName === undefined) { + if (typeof body.name !== "string" || body.name.trim() === "") { + badRequest("name is required and must be a non-empty string"); + } + name = body.name.trim(); + if (name.length > RESOURCE_NAME_MAX || !RESOURCE_NAME_PATTERN.test(name)) { + badRequest( + `name "${name}" is not a valid resource name (lowercase DNS-1123, max ${RESOURCE_NAME_MAX} chars)`, + ); + } + } else if (body.name !== undefined && body.name !== pathName) { + badRequest(`body.name "${String(body.name)}" does not match the path ("${pathName}")`); + } + if (!body.spec || typeof body.spec !== "object" || Array.isArray(body.spec)) { + badRequest("spec is required and must be an object"); + } + return { name, spec: body.spec as object }; +} + +/** Managed-label-stamped metadata for a catalog create. */ +function managedMetadata(name: string): object { + return { name, namespace: NAMESPACE, labels: { [MANAGED_LABEL]: "true" } }; +} + +interface CreateRunBody { + agentRef: string; + params?: Record; + instructions?: string; + applicationRef?: string; + targetBranch?: string; + /** Explicit "primary"-role selection; absent = default provider policy. */ + model?: { provider: string; model: string }; +} + +/** + * Secret carrying HUB_TOKEN into run pods. The token must never appear as a + * plaintext spec.env value: the CR would put it in etcd, and this shim — + * unauthenticated, CORS * — would echo it to every browser on run list/get. + * Instead the shim upserts this Secret once and injects a valueFrom ref; + * only the Secret NAME ever crosses the API. + */ +const HUB_TOKEN_SECRET = "hub-shim-hub-token"; +const HUB_TOKEN_SECRET_KEY = "token"; +let hubTokenSecretReady: Promise | undefined; + +function ensureHubTokenSecret(): Promise { + hubTokenSecretReady ??= (async () => { + const body = { + apiVersion: "v1", + kind: "Secret", + metadata: { name: HUB_TOKEN_SECRET, namespace: NAMESPACE }, + type: "Opaque", + stringData: { [HUB_TOKEN_SECRET_KEY]: HUB_TOKEN ?? "" }, + }; + try { + await core.replaceNamespacedSecret({ name: HUB_TOKEN_SECRET, namespace: NAMESPACE, body }); + } catch (err) { + if (k8sStatusCode(err) !== 404) throw err; + await core.createNamespacedSecret({ namespace: NAMESPACE, body }); + } + log(`hub token Secret ${HUB_TOKEN_SECRET} upserted`); + })(); + // A failed upsert must not poison every later create with the same + // rejected promise — retry next time. + hubTokenSecretReady.catch(() => { + hubTokenSecretReady = undefined; + }); + return hubTokenSecretReady; +} + +/** + * The platform's contribution to a run that works on an application: the + * Hub coordinates + application id + target branch, injected as spec.env + * (RUN_ENV). The Konveyor-aware harness pulls everything else — repo URL, + * decrypted git identity, analysis — from the Hub itself, in-pod, and + * withholds the credentials from the agent. This replaces the retired + * ADR 0005 param/credential-source resolution. + */ +async function hubEnvForRun( + applicationRef: string, + targetBranch: string | undefined, +): Promise { + // Creating an application-scoped run against stub data would inject a + // fabricated APP_ID the real Hub can't resolve. Not the caller's fault, + // so a plain Error (-> 5xx), unlike the 400s below. + const inv = await getApplications(); + if (inv.source !== "hub") { + throw new Error( + `application inventory unavailable (${inv.endpoint}) — cannot create an ` + + `application-scoped run against stub data; retry when the Hub is reachable`, + ); + } + const app = inv.applications.find((a) => a.id === applicationRef); + if (!app) { + badRequest( + `unknown applicationRef "${applicationRef}" — GET /api/applications lists the inventory`, + ); + } + if (!/^\d+$/.test(app.id)) { + badRequest( + `application id "${app.id}" is not numeric — the harness's APP_ID parser requires a ` + + `uint Hub id`, + ); + } + if (!app.repository?.url) { + badRequest( + `application "${app.id}" has no repository URL — the harness clones from the Hub ` + + `record; set the application's source repository first`, + ); + } + if (!RUN_HUB_BASE_URL) { + badRequest( + "applicationRef needs a Hub the SANDBOX POD can reach: set HUB_URL (in-cluster) or " + + "RUN_HUB_BASE_URL (laptop dev, pointing at the Hub's in-cluster service DNS)", + ); + } + // The pod dials this URL, not the shim: a laptop port-forward loopback + // address is unreachable from inside the cluster. + let hubHost: string; + try { + hubHost = new URL(RUN_HUB_BASE_URL).hostname; + } catch { + badRequest(`RUN_HUB_BASE_URL/HUB_URL "${RUN_HUB_BASE_URL}" is not a valid URL`); + } + if (ACP_DIAL === "tunnel" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hubHost)) { + badRequest( + `Hub base URL "${RUN_HUB_BASE_URL}" is loopback but the shim is not in-cluster — the ` + + `sandbox pod cannot reach the laptop's port-forward; set RUN_HUB_BASE_URL to the ` + + `Hub's in-cluster service DNS (e.g. http://tackle2-hub.konveyor-tackle.svc:8080)`, + ); + } + const branch = (targetBranch ?? defaultTargetBranch()).trim(); + const branchProblem = invalidTargetBranchReason(branch); + if (branchProblem) { + badRequest(`targetBranch "${branch}" is ${branchProblem}`); + } + if (app.repository?.branch && branch === app.repository.branch) { + badRequest( + `targetBranch "${branch}" equals application "${app.id}"'s source branch — the harness ` + + `refuses to push onto the source branch; pick any other name`, + ); + } + const env: EnvVar[] = [ + { name: RUN_ENV.HUB_BASE_URL, value: RUN_HUB_BASE_URL }, + { name: RUN_ENV.APP_ID, value: app.id }, + { name: RUN_ENV.TARGET_BRANCH, value: branch }, + ]; + if (HUB_TOKEN) { + await ensureHubTokenSecret(); + env.splice(1, 0, { + name: RUN_ENV.HUB_TOKEN, + valueFrom: { secretKeyRef: { name: HUB_TOKEN_SECRET, key: HUB_TOKEN_SECRET_KEY } }, + }); + } + return env; +} + +/** + * Fetches an Agent, tolerating 404 (undefined) — single-run creation leaves + * an unknown agentRef for the controller to report, matching the CR-level + * behavior a kubectl user would see. + */ +async function fetchAgent(agentRef: string): Promise { + try { + return (await getCustom(PLURALS.Agent, "Agent", agentRef)) as Agent; + } catch (err) { + if (k8sStatusCode(err) === 404) return undefined; + throw err; + } +} + +/** + * The migration-harness fails fatally when /opt/skills mounts nothing, so an + * application-scoped run on a skill-less Agent is doomed. A warning rather + * than a 400: fixtures (mock harness) legitimately run skill-less, and the + * shim cannot tell a fixture from a misconfigured migration agent. + */ +function warnIfNoSkills(agent: Agent | undefined, forWhom: string): void { + if (!agent?.metadata?.name) return; + const skills = (agent.spec.skillCards?.length ?? 0) + (agent.spec.skillCollections?.length ?? 0); + if (skills === 0) { + warn( + `${forWhom}: Agent "${agent.metadata.name}" declares no skillCards/skillCollections — ` + + `the migration-harness fatals with zero mounted skills`, + ); + } +} + +/** + * The run's model selection + LLM-provider credentials, defaulted from the + * Agent's declared providers — the platform-side policy the controller does + * not perform for itself. + * + * The controller turns spec.models into KONVEYOR_MODEL_{ROLE}_* env. Since + * #34 it also handles SigV4-style providers itself: a keyless credentialRef + * exposes the whole credential Secret to the sandbox via envFrom. The + * envFrom we add here duplicates that for keyless providers (same secret, + * harmless) and remains the only credential path against pre-#34 + * controllers. The secretRef is `optional` so a provider whose secret has + * not been created (e.g. the mock provider) still lets the run start — the + * harness warns at runtime instead of the pod wedging on a missing Secret. + * + * Defaults to the Agent's first declared provider and that provider's + * primary-tier model (else its first). Best-effort: an agent with no + * provider, an unresolvable LLMProvider, or a provider with no models + * contributes nothing — fine for fixtures, but the migration-harness has + * NO model defaults (KONVEYOR_MODEL_PRIMARY_* is hard-required, it fails + * at startup without them), so the create paths warn when an + * application-scoped run resolves no model. + */ +async function resolveModels( + agent: Agent | undefined, + agentRef: string, +): Promise<{ models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }> { + const empty = { models: [] as AgentRunModelSelection[], envFrom: [] as EnvFromSource[] }; + // Unknown agent: let createAgentRun proceed and the controller report it. + if (!agent) return empty; + const providerRef = agent.spec.providers?.[0]?.ref; + if (!providerRef) return empty; + return resolveProviderModel(providerRef, `agent "${agentRef}"`); +} + +/** + * Model selection for a playbook run. spec.models applies to EVERY stage + * and there are no per-stage overrides, so the pick must come from the + * INTERSECTION of the stage Agents' full declared provider lists (the same + * set an explicit selection validates against) — a provider only some + * stages declare would fail those stages at reconcile. An empty + * intersection is a 400, not a silent pick; the first stage Agent's + * declaration order breaks ties. + */ +async function resolvePlaybookModels( + playbookRef: string, + agents: Agent[], +): Promise<{ models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }> { + const declaring = agents.filter((a) => (a.spec.providers ?? []).length > 0); + if (declaring.length === 0) return { models: [], envFrom: [] }; + const shared = (declaring[0].spec.providers ?? []) + .map((p) => p.ref) + .filter((ref) => declaring.every((a) => (a.spec.providers ?? []).some((p) => p.ref === ref))); + if (shared.length === 0) { + const perStage = declaring + .map((a) => `${a.metadata.name}: ${(a.spec.providers ?? []).map((p) => p.ref).join("/")}`) + .join("; "); + badRequest( + `playbook "${playbookRef}" stage Agents share no LLM provider (${perStage}) — ` + + `spec.models applies to every stage and per-stage overrides do not exist yet`, + ); + } + return resolveProviderModel(shared[0], `playbook "${playbookRef}"`); +} + +/** The provider's primary-tier model (else first) + its credential Secret. */ +async function resolveProviderModel( + providerRef: string, + forWhom: string, +): Promise<{ models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }> { + const empty = { models: [] as AgentRunModelSelection[], envFrom: [] as EnvFromSource[] }; + let provider: LLMProvider; + try { + provider = (await getCustom(PLURALS.LLMProvider, "LLMProvider", providerRef)) as LLMProvider; + } catch (err) { + if (k8sStatusCode(err) === 404) { + log(`${forWhom} declares provider "${providerRef}" but no such LLMProvider — no model injected`); + return empty; + } + throw err; + } + + const model = + (provider.spec.models?.find((m) => m.tier === "primary") ?? provider.spec.models?.[0])?.name; + if (!model) { + log(`LLMProvider "${providerRef}" lists no models — no model injected`); + return empty; + } + + const models: AgentRunModelSelection[] = [{ role: "primary", provider: providerRef, model }]; + const secretName = provider.spec.credentialRef?.secretName; + const envFrom: EnvFromSource[] = secretName + ? [{ secretRef: { name: secretName, optional: true } }] + : []; + warnUnknownGooseProvider(providerRef); + log( + `model: ${providerRef}/${model} for ${forWhom}` + + (secretName ? ` (+creds secret ${secretName})` : ""), + ); + return { models, envFrom }; +} + +/** + * An EXPLICIT caller model selection ({provider, model} on the create body). + * Unlike the default policy — which is best-effort, because the caller asked + * for nothing — an explicit choice fails loudly: a provider outside the + * (stage) Agents' declared providers, an unknown LLMProvider CR, or an + * undeclared model are 400s, since the controller/harness would only reject + * them later and less legibly. `agents` holds every known Agent the + * selection must satisfy (single run: the one agent, when it resolves; + * playbook: every stage agent). + */ +async function resolveExplicitModel( + choice: { provider: string; model: string }, + agents: Agent[], +): Promise<{ models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }> { + for (const agent of agents) { + const declared = (agent.spec.providers ?? []).map((p) => p.ref); + if (!declared.includes(choice.provider)) { + badRequest( + `model.provider "${choice.provider}" is not among Agent "${agent.metadata.name}"'s ` + + `declared providers (${declared.join(", ") || "none"})`, + ); + } + } + let provider: LLMProvider; + try { + provider = (await getCustom( + PLURALS.LLMProvider, + "LLMProvider", + choice.provider, + )) as LLMProvider; + } catch (err) { + if (k8sStatusCode(err) === 404) { + badRequest( + `unknown LLMProvider "${choice.provider}" — GET /api/llmproviders lists them`, + ); + } + throw err; + } + if (!provider.spec.models?.some((m) => m.name === choice.model)) { + const declared = (provider.spec.models ?? []).map((m) => m.name); + badRequest( + `model "${choice.model}" is not declared on LLMProvider "${choice.provider}" ` + + `(declared: ${declared.join(", ") || "none"})`, + ); + } + const models: AgentRunModelSelection[] = [ + { role: "primary", provider: choice.provider, model: choice.model }, + ]; + const secretName = provider.spec.credentialRef?.secretName; + const envFrom: EnvFromSource[] = secretName + ? [{ secretRef: { name: secretName, optional: true } }] + : []; + warnUnknownGooseProvider(choice.provider); + log(`model: ${choice.provider}/${choice.model} (explicit caller selection)`); + return { models, envFrom }; +} + +/** + * goose provider ids the migration-harness's verbatim name mapping can hit + * (CR name lowercased, "-" -> "_"). Advisory only — goose grows providers, + * so an unknown id is a warning, never a 400. + */ +const KNOWN_GOOSE_PROVIDER_IDS = new Set([ + "anthropic", + "aws_bedrock", + "azure_openai", + "databricks", + "gcp_vertex_ai", + "google", + "groq", + "litellm", + "ollama", + "openai", + "openrouter", + "xai", +]); + +/** + * The migration-harness maps the LLMProvider CR NAME to a goose provider id + * verbatim (lowercased, "-" -> "_") — a CR named "bedrock" no longer means + * aws_bedrock. Warn at create time so the misname surfaces here instead of + * as a goose startup failure inside the pod. + */ +function warnUnknownGooseProvider(providerRef: string): void { + const gooseId = providerRef.toLowerCase().replace(/-/g, "_"); + if (!KNOWN_GOOSE_PROVIDER_IDS.has(gooseId)) { + warn( + `LLMProvider "${providerRef}" maps to goose provider id "${gooseId}", which is not a ` + + `known goose provider — the harness maps CR names verbatim; if goose rejects it, ` + + `rename the CR (e.g. "aws-bedrock" -> aws_bedrock)`, + ); + } +} + +/** Validates a body's optional params field as an object of string values. */ +function parseParamsField(body: Record): Record | undefined { + if (body.params === undefined) return undefined; + if (!body.params || typeof body.params !== "object" || Array.isArray(body.params)) { + badRequest("params must be an object of string values"); + } + const params: Record = {}; + for (const [key, value] of Object.entries(body.params as Record)) { + if (typeof value !== "string") { + badRequest(`params.${key} must be a string`); + } + params[key] = value; + } + return params; +} + +/** Validates an optional model field: {provider, model}, both non-empty. */ +function parseModelField( + body: Record, +): { provider: string; model: string } | undefined { + if (body.model === undefined) return undefined; + const m = body.model; + if (!m || typeof m !== "object" || Array.isArray(m)) { + badRequest('model must be an object: {"provider": "...", "model": "..."}'); + } + const { provider, model } = m as Record; + if (typeof provider !== "string" || provider.trim() === "") { + badRequest("model.provider must be a non-empty string"); + } + if (typeof model !== "string" || model.trim() === "") { + badRequest("model.model must be a non-empty string"); + } + return { provider: provider.trim(), model: model.trim() }; +} + +/** + * Validates an optional targetBranch field: non-empty and git-refname-valid + * (shared rules with the UI via invalidTargetBranchReason — the harness + * would only fail later, inside the pod). + */ +function parseTargetBranchField(body: Record): string | undefined { + if (body.targetBranch === undefined) return undefined; + if (typeof body.targetBranch !== "string" || body.targetBranch.trim() === "") { + badRequest("targetBranch must be a non-empty string"); + } + const branch = body.targetBranch.trim(); + const problem = invalidTargetBranchReason(branch); + if (problem) badRequest(`targetBranch "${branch}" is ${problem}`); + return branch; +} + +/** Validates the POST /api/agentruns body; throws with a client-facing message. */ +function parseCreateRunBody(raw: unknown): CreateRunBody { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + badRequest( + "body must be a JSON object: {agentRef, params?, instructions?, applicationRef?, targetBranch?, model?}", + ); + } + const body = raw as Record; + if (typeof body.agentRef !== "string" || body.agentRef.trim() === "") { + badRequest("agentRef is required and must be a non-empty string"); + } + const params = parseParamsField(body); + if (body.instructions !== undefined && typeof body.instructions !== "string") { + badRequest("instructions must be a string"); + } + if ( + body.applicationRef !== undefined && + (typeof body.applicationRef !== "string" || body.applicationRef.trim() === "") + ) { + badRequest("applicationRef must be a non-empty string"); + } + const targetBranch = parseTargetBranchField(body); + if (targetBranch !== undefined && body.applicationRef === undefined) { + badRequest("targetBranch is only meaningful with applicationRef"); + } + return { + agentRef: body.agentRef, + params, + instructions: body.instructions as string | undefined, + applicationRef: body.applicationRef as string | undefined, + targetBranch, + model: parseModelField(body), + }; +} + +interface CreatePlaybookRunBody { + playbookRef: string; + params?: Record; + applicationRef?: string; + targetBranch?: string; + /** Explicit "primary"-role selection, applied to EVERY stage. */ + model?: { provider: string; model: string }; +} + +/** + * Validates the POST /api/agentplaybookruns body. Deliberately NO + * instructions field: AgentPlaybookRun.spec has none — stage instructions + * come from the playbook itself. + */ +function parseCreatePlaybookRunBody(raw: unknown): CreatePlaybookRunBody { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + badRequest( + "body must be a JSON object: {playbookRef, params?, applicationRef?, targetBranch?, model?}", + ); + } + const body = raw as Record; + if (typeof body.playbookRef !== "string" || body.playbookRef.trim() === "") { + badRequest("playbookRef is required and must be a non-empty string"); + } + const params = parseParamsField(body); + if ( + body.applicationRef !== undefined && + (typeof body.applicationRef !== "string" || body.applicationRef.trim() === "") + ) { + badRequest("applicationRef must be a non-empty string"); + } + const targetBranch = parseTargetBranchField(body); + if (targetBranch !== undefined && body.applicationRef === undefined) { + badRequest("targetBranch is only meaningful with applicationRef"); + } + return { + playbookRef: body.playbookRef, + params, + applicationRef: body.applicationRef as string | undefined, + targetBranch, + model: parseModelField(body), + }; +} + +/** + * Loads a playbook and every stage Agent (deduped). Unlike single-run + * creation — where an unknown agentRef is left for the controller to + * report — a playbook create fails fast on a missing Agent: params and + * models resolve against the UNION of stage Agents, so a hole in that + * union would silently mis-resolve the run. + */ +async function loadPlaybookAgents( + playbookRef: string, +): Promise<{ playbook: AgentPlaybook; agents: Agent[] }> { + let playbook: AgentPlaybook; + try { + playbook = (await getCustom( + PLURALS.AgentPlaybook, + "AgentPlaybook", + playbookRef, + )) as AgentPlaybook; + } catch (err) { + if (k8sStatusCode(err) === 404) { + badRequest(`unknown playbookRef "${playbookRef}" — GET /api/agentplaybooks lists them`); + } + throw err; + } + const refs = [...new Set((playbook.spec.stages ?? []).map((s) => s.agentRef))]; + const agents: Agent[] = []; + for (const ref of refs) { + try { + agents.push((await getCustom(PLURALS.Agent, "Agent", ref)) as Agent); + } catch (err) { + if (k8sStatusCode(err) === 404) { + badRequest(`playbook "${playbookRef}" references unknown Agent "${ref}"`); + } + throw err; + } + } + return { playbook, agents }; +} + +/** + * The agent-image catalog: the managed ConfigMap when present (cluster data + * an admin can edit), else the built-in upstream hierarchy — same + * stub-fallback idiom as the application inventory, with provenance. + */ +async function getImageCatalog(): Promise<{ + images: AgentImage[]; + source: "configmap" | "builtin"; +}> { + let raw: string | undefined; + try { + const cm = await core.readNamespacedConfigMap({ + name: IMAGE_CATALOG_CONFIGMAP, + namespace: NAMESPACE, + }); + raw = cm.data?.[IMAGE_CATALOG_KEY]; + } catch (err) { + if (k8sStatusCode(err) !== 404) throw err; + } + if (raw !== undefined) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return { images: parsed as AgentImage[], source: "configmap" }; + } + warn(`ConfigMap ${IMAGE_CATALOG_CONFIGMAP}'s ${IMAGE_CATALOG_KEY} is not a JSON array`); + } catch { + warn(`ConfigMap ${IMAGE_CATALOG_CONFIGMAP}'s ${IMAGE_CATALOG_KEY} is not valid JSON`); + } + } + return { images: DEFAULT_IMAGE_CATALOG, source: "builtin" }; +} + +/** + * Seeds the default managed resource set (see defaults.ts) plus the image + * catalog ConfigMap. Create-only: 409 AlreadyExists reports "exists" and + * moves on, so re-seeding never clobbers local edits. Any other apiserver + * error aborts the pass — partial seeds are re-runnable thanks to the + * idempotency. + */ +async function seedDefaults(): Promise { + const results: SeedResult[] = []; + + try { + await core.createNamespacedConfigMap({ + namespace: NAMESPACE, + body: imageCatalogConfigMap(NAMESPACE), + }); + results.push({ kind: "ConfigMap", name: IMAGE_CATALOG_CONFIGMAP, status: "created" }); + } catch (err) { + if (k8sStatusCode(err) !== 409) throw err; + results.push({ kind: "ConfigMap", name: IMAGE_CATALOG_CONFIGMAP, status: "exists" }); + } + + for (const r of defaultResources()) { + try { + await custom.createNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural: r.plural, + body: { ...r.body, metadata: { ...(r.body.metadata as object), namespace: NAMESPACE } }, + }); + results.push({ kind: r.kind, name: r.name, status: "created" }); + } catch (err) { + if (k8sStatusCode(err) !== 409) throw err; + results.push({ kind: r.kind, name: r.name, status: "exists" }); + } + } + + const created = results.filter((r) => r.status === "created").length; + log(`seeded defaults: ${created} created, ${results.length - created} already existed`); + return results; +} + +async function handleApi( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string, +): Promise { + const method = req.method ?? "GET"; + + if (pathname === "/api/images") { + if (method !== "GET") return sendError(res, 405, "method not allowed"); + const catalog = await getImageCatalog(); + // Same shape as the inventory: bare array body, provenance in a header. + return sendJson(res, 200, catalog.images, { "X-Catalog-Source": catalog.source }); + } + + if (pathname === "/api/defaults") { + if (method !== "POST") return sendError(res, 405, "method not allowed"); + return sendJson(res, 200, await seedDefaults()); + } + + if (pathname === "/api/applications") { + if (method !== "GET") return sendError(res, 405, "method not allowed"); + const inv = await getApplications(); + // Body stays a bare Application[] (unchanged contract). Provenance rides + // in headers so the UI can show real-vs-stub without a shape change. + return sendJson(res, 200, inv.applications, { + "X-Inventory-Source": inv.source, + "X-Inventory-Endpoint": inv.endpoint, + }); + } + + // Playbook-run writes come before the read-only dispatch, whose regex + // also matches these paths (and would answer 405 for non-GET methods). + if (pathname === "/api/agentplaybookruns" && method === "POST") { + let input: CreatePlaybookRunBody; + let agents: Agent[]; + let hubEnv: EnvVar[]; + let modelSel: { models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }; + try { + input = parseCreatePlaybookRunBody(await readJsonBody(req)); + agents = (await loadPlaybookAgents(input.playbookRef)).agents; + // Params forward wholesale to every stage; a param some stage Agent + // doesn't declare means that stage's AgentRun gets created and + // immediately marked Failed (reason=InvalidParams) by the controller. + // Deterministically doomed, so reject at create time. + for (const name of Object.keys(input.params ?? {})) { + const missing = agents.filter((a) => !a.spec.params?.some((p) => p.name === name)); + if (missing.length > 0) { + badRequest( + `param "${name}" is not declared by stage Agent(s) ` + + `${missing.map((a) => a.metadata.name).join(", ")} — params forward to every ` + + `stage, and the controller marks those stages Failed (InvalidParams) ` + + `immediately; drop the param or declare it on those Agents`, + ); + } + } + hubEnv = input.applicationRef + ? await hubEnvForRun(input.applicationRef, input.targetBranch) + : []; + // An explicit caller selection must satisfy EVERY stage Agent (models + // apply to all stages); otherwise the default shared-provider policy. + modelSel = input.model + ? await resolveExplicitModel(input.model, agents) + : await resolvePlaybookModels(input.playbookRef, agents); + } catch (err) { + // Only caller faults are 400; apiserver transport failures are 5xx. + if (!(err instanceof BadRequestError)) throw err; + return sendError(res, 400, errorMessage(err)); + } + if (input.applicationRef) { + for (const agent of agents) { + warnIfNoSkills(agent, `playbook run (${input.playbookRef})`); + } + if (modelSel.models.length === 0) { + warn( + `playbook run (${input.playbookRef}): no primary model resolved — the ` + + `migration-harness hard-requires KONVEYOR_MODEL_PRIMARY_MODEL/PROVIDER and ` + + `will fail at startup`, + ); + } + } + const spec: AgentPlaybookRunSpec = { playbookRef: input.playbookRef }; + const params = { ...(input.params ?? {}) }; + if (Object.keys(params).length > 0) { + spec.params = Object.entries(params).map(([name, value]) => ({ name, value })); + } + if (modelSel.models.length > 0) spec.models = modelSel.models; + // Hub coordinates + TARGET_BRANCH forward verbatim to every stage's + // AgentRun — one shared branch is how the stages chain their work. + if (hubEnv.length > 0) spec.env = hubEnv; + if (modelSel.envFrom.length > 0) spec.envFrom = modelSel.envFrom; + const created = (await custom.createNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural: PLURALS.AgentPlaybookRun, + body: { + apiVersion: API_VERSION, + kind: "AgentPlaybookRun", + // Managed label: everything the platform creates is stamped (the + // run LISTS stay unfiltered — the label is provenance, not a gate). + metadata: { + generateName: "ui-", + namespace: NAMESPACE, + labels: { [MANAGED_LABEL]: "true" }, + }, + spec, + } satisfies AgentPlaybookRun, + })) as AgentPlaybookRun; + const via = input.applicationRef ? ` via application=${input.applicationRef}` : ""; + log( + `created AgentPlaybookRun ${created.metadata.name} (playbookRef=${input.playbookRef}${via})`, + ); + return sendJson(res, 201, created); + } + + const playbookRunMatch = /^\/api\/agentplaybookruns\/([^/]+)$/.exec(pathname); + if (playbookRunMatch && method === "DELETE") { + const name = decodeURIComponent(playbookRunMatch[1]); + try { + await custom.deleteNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural: PLURALS.AgentPlaybookRun, + name, + }); + } catch (err) { + if (k8sStatusCode(err) === 404) { + return sendError(res, 404, `AgentPlaybookRun ${name} not found`); + } + throw err; + } + log(`deleted AgentPlaybookRun ${name} (stage AgentRuns cascade via ownerRefs)`); + res.writeHead(204).end(); + return; + } + + // Catalog writes (agents / skillcards / skillcollections / agentplaybooks). + // Before the read-only dispatch, whose regex also matches these paths and + // would answer 405 for non-GET. Thin passthrough: the CRD schema + CEL + // rules on the apiserver validate specs; its message passes through + // (k8sMessage) with its status code (409 exists/conflict, 422 invalid). + const writeMatch = /^\/api\/([a-z]+)(?:\/([^/]+))?$/.exec(pathname); + if (writeMatch && WRITABLE[writeMatch[1]] && method !== "GET") { + const plural = writeMatch[1]; + const kind = WRITABLE[plural]; + const name = writeMatch[2] === undefined ? undefined : decodeURIComponent(writeMatch[2]); + + if (method === "POST" && name === undefined) { + let input: SaveResourceBody; + try { + input = parseSaveBody(await readJsonBody(req), undefined); + } catch (err) { + if (!(err instanceof BadRequestError)) throw err; + return sendError(res, 400, errorMessage(err)); + } + let created: object; + try { + created = (await custom.createNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural, + body: { + apiVersion: API_VERSION, + kind, + metadata: managedMetadata(input.name as string), + spec: input.spec, + }, + })) as object; + } catch (err) { + const status = k8sStatusCode(err); + if (status !== undefined && status < 500) return sendError(res, status, k8sMessage(err)); + throw err; + } + log(`created ${kind} ${input.name}`); + return sendJson(res, 201, { apiVersion: API_VERSION, kind, ...created }); + } + + if (method === "PUT" && name !== undefined) { + let input: SaveResourceBody; + try { + input = parseSaveBody(await readJsonBody(req), name); + } catch (err) { + if (!(err instanceof BadRequestError)) throw err; + return sendError(res, 400, errorMessage(err)); + } + // Read-modify-write: keep metadata (resourceVersion gives optimistic + // concurrency — a concurrent write surfaces as 409), replace the spec, + // stamp the managed label. Editing an unlabeled resource deliberately + // ADOPTS it into the platform: it appears in managed lists afterwards. + let current: { metadata?: { labels?: Record } }; + try { + current = (await getCustom(plural, kind, name)) as { + metadata?: { labels?: Record }; + }; + } catch (err) { + if (k8sStatusCode(err) === 404) return sendError(res, 404, `${kind} ${name} not found`); + throw err; + } + let replaced: object; + try { + replaced = (await custom.replaceNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural, + name, + body: { + apiVersion: API_VERSION, + kind, + metadata: { + ...(current.metadata ?? {}), + labels: { ...(current.metadata?.labels ?? {}), [MANAGED_LABEL]: "true" }, + }, + spec: input.spec, + }, + })) as object; + } catch (err) { + const status = k8sStatusCode(err); + if (status !== undefined && status < 500) return sendError(res, status, k8sMessage(err)); + throw err; + } + log(`updated ${kind} ${name}`); + return sendJson(res, 200, { apiVersion: API_VERSION, kind, ...replaced }); + } + + if (method === "DELETE" && name !== undefined) { + try { + await custom.deleteNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: NAMESPACE, + plural, + name, + }); + } catch (err) { + if (k8sStatusCode(err) === 404) return sendError(res, 404, `${kind} ${name} not found`); + throw err; + } + log(`deleted ${kind} ${name}`); + res.writeHead(204).end(); + return; + } + + return sendError(res, 405, "method not allowed"); + } + + const roMatch = /^\/api\/([a-z]+)(?:\/([^/]+))?$/.exec(pathname); + if (roMatch && READ_ONLY[roMatch[1]]) { + if (method !== "GET") return sendError(res, 405, "method not allowed"); + const plural = roMatch[1]; + const kind = READ_ONLY[plural]; + if (!roMatch[2]) { + return sendJson(res, 200, await listCustom(plural, kind, LIST_LABEL_SELECTORS[plural])); + } + const name = decodeURIComponent(roMatch[2]); + try { + return sendJson(res, 200, await getCustom(plural, kind, name)); + } catch (err) { + if (k8sStatusCode(err) === 404) return sendError(res, 404, `${kind} ${name} not found`); + throw err; + } + } + + if (pathname === "/api/agentruns") { + if (method === "GET") { + return sendJson(res, 200, await listCustom(PLURALS.AgentRun, "AgentRun")); + } + if (method === "POST") { + let input: CreateRunBody; + let agent: Agent | undefined; + let hubEnv: EnvVar[]; + let modelSel: { models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }; + try { + input = parseCreateRunBody(await readJsonBody(req)); + agent = await fetchAgent(input.agentRef); + hubEnv = input.applicationRef + ? await hubEnvForRun(input.applicationRef, input.targetBranch) + : []; + // Explicit selection validates against the agent when it resolves + // (an unknown agentRef stays the controller's to report); absent, + // the default first-provider/primary-tier policy applies. + modelSel = input.model + ? await resolveExplicitModel(input.model, agent ? [agent] : []) + : await resolveModels(agent, input.agentRef); + } catch (err) { + // Only caller faults are 400. hubEnvForRun/resolveModels talk to the + // apiserver inside this try, and a transport failure there is a 5xx. + if (!(err instanceof BadRequestError)) throw err; + return sendError(res, 400, errorMessage(err)); + } + if (input.applicationRef) { + warnIfNoSkills(agent, `run (${input.agentRef})`); + if (modelSel.models.length === 0) { + warn( + `run (${input.agentRef}): no primary model resolved — the migration-harness ` + + `hard-requires KONVEYOR_MODEL_PRIMARY_MODEL/PROVIDER and will fail at startup`, + ); + } + } + const spec: AgentRunSpec = { agentRef: input.agentRef }; + const params = { ...(input.params ?? {}) }; + if (Object.keys(params).length > 0) { + spec.params = Object.entries(params).map(([name, value]) => ({ name, value })); + } + if (input.instructions !== undefined) spec.instructions = input.instructions; + if (modelSel.models.length > 0) spec.models = modelSel.models; + // Hub coordinates + TARGET_BRANCH ride spec.env; the LLM provider's + // credential Secret rides envFrom. + if (hubEnv.length > 0) spec.env = hubEnv; + if (modelSel.envFrom.length > 0) spec.envFrom = modelSel.envFrom; + const run = await runClient.createAgentRun(spec, { + generateName: "ui-", + // Provenance stamp; run lists stay unfiltered. + labels: { [MANAGED_LABEL]: "true" }, + }); + const via = input.applicationRef ? ` via application=${input.applicationRef}` : ""; + log(`created AgentRun ${run.metadata.name} (agentRef=${input.agentRef}${via})`); + return sendJson(res, 201, run); + } + return sendError(res, 405, "method not allowed"); + } + + const runMatch = /^\/api\/agentruns\/([^/]+)$/.exec(pathname); + if (runMatch) { + const name = decodeURIComponent(runMatch[1]); + if (method === "GET") { + try { + return sendJson(res, 200, await runClient.getAgentRun(name)); + } catch (err) { + if (k8sStatusCode(err) === 404) return sendError(res, 404, `AgentRun ${name} not found`); + throw err; + } + } + if (method === "DELETE") { + try { + await runClient.deleteAgentRun(name); + } catch (err) { + if (k8sStatusCode(err) === 404) return sendError(res, 404, `AgentRun ${name} not found`); + throw err; + } + log(`deleted AgentRun ${name}`); + res.writeHead(204).end(); + return; + } + return sendError(res, 405, "method not allowed"); + } + + sendError(res, 404, `no route for ${pathname}`); +} + +/** + * Request-target parse that cannot take the process down. Node's HTTP parser + * delivers targets WHATWG URL refuses (e.g. "//" — protocol-relative with an + * empty host), and the throw would be synchronous inside the listener where + * no promise .catch protects it — one stray request from a scanner would + * kill every in-flight ACP bridge. + */ +function safePathname(target: string | undefined): string | undefined { + try { + return new URL(target ?? "/", "http://localhost").pathname; + } catch { + return undefined; + } +} + +const server = http.createServer((req, res) => { + const pathname = safePathname(req.url); + if (pathname === undefined) { + sendError(res, 400, `malformed request target ${req.url ?? ""}`); + return; + } + + if (pathname === "/healthz") { + res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("ok"); + return; + } + + if (!pathname.startsWith("/api/")) { + sendError(res, 404, `no route for ${pathname}`); + return; + } + + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader( + "Access-Control-Expose-Headers", + "X-Inventory-Source, X-Inventory-Endpoint, X-Catalog-Source", + ); + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }); + res.end(); + return; + } + + handleApi(req, res, pathname).catch((err: unknown) => { + // URIError = malformed percent-encoding in a path segment — a client + // fault (400), not a shim outage. k8s ApiExceptions keep their status + // and get the apiserver Status MESSAGE (k8sMessage), not the client + // library's multi-line blob — same contract the catalog writes honor. + const status = err instanceof URIError ? 400 : (k8sStatusCode(err) ?? 500); + warn(`${req.method} ${pathname} failed: ${errorMessage(err)}`); + if (!res.headersSent) sendError(res, status, k8sMessage(err)); + else res.end(); + }); +}); + +// ------------------------------------------------------------- WS acp pipe + +/** Close codes a ws socket is allowed to SEND (mirrors ws's validation). */ +function sendableCloseCode(code: number, fallback: number): number { + if (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code; + if (code >= 3000 && code <= 4999) return code; + return fallback; +} + +/** Close reasons are capped at 123 UTF-8 bytes by the WebSocket protocol. */ +function closeReason(text: string): string { + let reason = text.replace(/\s+/g, " ").trim().slice(0, 123); + while (Buffer.byteLength(reason, "utf8") > 123) reason = reason.slice(0, -1); + return reason; +} + +async function bridgeAcp(client: WsWebSocket, runName: string): Promise { + const tag = `acp ${runName}:`; + log(`${tag} browser client connected`); + + let upstream: WsWebSocket | undefined; + let tunnel: Tunnel | undefined; + let clientClosed = false; + let keepalive: NodeJS.Timeout | undefined; + /** Frames the browser sent before the upstream socket finished opening. */ + const pendingToUpstream: { data: RawData; isBinary: boolean }[] = []; + + client.on("message", (data: RawData, isBinary: boolean) => { + if (upstream && upstream.readyState === WsWebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + } else { + pendingToUpstream.push({ data, isBinary }); + } + }); + + client.on("close", (code: number, reason: Buffer) => { + clientClosed = true; + clearInterval(keepalive); + log(`${tag} client closed (code=${code}${reason.length ? ` reason=${reason.toString()}` : ""})`); + if (upstream) { + if (upstream.readyState === WsWebSocket.OPEN) { + upstream.close(sendableCloseCode(code, 1000), closeReason(reason.toString())); + } else { + upstream.terminate(); + } + } + tunnel?.close(); + }); + + client.on("error", (err: Error) => { + warn(`${tag} client socket error: ${err.message}`); + }); + + try { + const endpoint = await runClient.waitForAcpEndpoint(runName, { + timeoutMs: ACP_RESOLVE_TIMEOUT_MS, + }); + if (clientClosed) return; + + let target: string; + if (ACP_DIAL === "direct") { + // In-cluster: the headless Service's DNS name resolves straight to + // the pod IP; no port-forward machinery needed. + target = `ws://${endpoint.serviceHost}:${endpoint.port}/acp`; + log(`${tag} resolved pod ${endpoint.podName}, dialing ${endpoint.serviceHost}:${endpoint.port}`); + } else { + tunnel = await openTunnel(runClient.kc, NAMESPACE, endpoint.podName, endpoint.port); + if (clientClosed) { + tunnel.close(); + return; + } + log(`${tag} resolved pod ${endpoint.podName}, tunnel 127.0.0.1:${tunnel.localPort}`); + target = `ws://127.0.0.1:${tunnel.localPort}/acp`; + } + // The shim injects the X-Secret-Key header the browser cannot set. + // connectUpstream retries the dial while the pod's :4000 is not yet + // accepting connections, so a pod that reports Running/Ready before the + // harness has bound doesn't surface to the browser as a fatal 1011. + upstream = await connectUpstream(target, { + secretKey: endpoint.secretKey, + tag, + isClientClosed: () => clientClosed, + log, + }); + if (clientClosed) { + upstream.terminate(); + tunnel?.close(); + return; + } + log(`${tag} upstream open, piping frames`); + for (const frame of pendingToUpstream.splice(0)) { + upstream.send(frame.data, { binary: frame.isBinary }); + } + + // Liveness: any frame from the upstream (data or pong) proves the path + // is alive; an interval with neither means the peer or the tunnel died + // without a close frame. RFC 6455 obliges every endpoint to answer pings, + // so an idle-but-healthy agent mid-turn still pongs. + let upstreamAlive = true; + upstream.on("pong", () => { + upstreamAlive = true; + }); + if (ACP_KEEPALIVE_MS > 0) { + const up = upstream; + keepalive = setInterval(() => { + if (up.readyState !== WsWebSocket.OPEN) return; + if (!upstreamAlive) { + warn(`${tag} upstream unresponsive for ${ACP_KEEPALIVE_MS}ms, terminating`); + clearInterval(keepalive); + if (!clientClosed) { + client.close(1011, closeReason("upstream unresponsive (keepalive timeout)")); + } + // terminate() fires 'close' below, which handles tunnel cleanup. + up.terminate(); + return; + } + upstreamAlive = false; + up.ping(); + }, ACP_KEEPALIVE_MS); + } + + upstream.on("message", (data: RawData, isBinary: boolean) => { + upstreamAlive = true; + if (client.readyState === WsWebSocket.OPEN) client.send(data, { binary: isBinary }); + }); + + upstream.on("close", (code: number, reason: Buffer) => { + log(`${tag} upstream closed (code=${code})`); + clearInterval(keepalive); + tunnel?.close(); + if (!clientClosed) { + client.close( + sendableCloseCode(code, 1011), + closeReason(reason.toString() || "upstream closed"), + ); + } + }); + + upstream.on("error", (err: Error) => { + warn(`${tag} upstream error: ${err.message}`); + clearInterval(keepalive); + tunnel?.close(); + if (!clientClosed) client.close(1011, closeReason(`upstream error: ${err.message}`)); + }); + } catch (err) { + const message = + k8sStatusCode(err) === 404 ? `AgentRun ${runName} not found` : errorMessage(err); + warn(`${tag} failed to reach ACP endpoint: ${message}`); + tunnel?.close(); + if (!clientClosed) client.close(1011, closeReason(message)); + } +} + +const wss = new WebSocketServer({ noServer: true }); + +server.on("upgrade", (req, socket, head) => { + // No promise .catch protects this listener — a throw here (malformed + // target, bad percent-encoding) would kill the process and with it every + // in-flight ACP bridge. Both hazards answer 400 instead. + const pathname = safePathname(req.url); + const match = pathname && /^\/api\/agentruns\/([^/]+)\/acp$/.exec(pathname); + if (pathname === undefined) { + socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + if (!match) { + socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + let runName: string; + try { + runName = decodeURIComponent(match[1]); + } catch { + socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + // Always accept the upgrade first so failures surface to the browser as a + // close frame (1011 + reason) instead of an opaque handshake error. + wss.handleUpgrade(req, socket, head, (client) => { + void bridgeAcp(client, runName); + }); +}); + +server.listen(PORT, HOST, () => { + log(`SHIM API v1 listening on http://${HOST}:${PORT} (namespace=${NAMESPACE}, acp-dial=${ACP_DIAL})`); + log( + `routes: GET /healthz | GET /api/applications | GET /api/images | POST /api/defaults | GET|POST /api/{agents,agentplaybooks,skillcards,skillcollections} | GET|PUT|DELETE /api/{agents,agentplaybooks,skillcards,skillcollections}/:name | GET /api/llmproviders[/:name] | GET|POST /api/agentruns | GET|DELETE /api/agentruns/:name | WS /api/agentruns/:name/acp | GET|POST /api/agentplaybookruns | GET|DELETE /api/agentplaybookruns/:name`, + ); +}); + +process.on("SIGINT", () => { + log("shutting down"); + wss.clients.forEach((c) => c.close(1001, "hub-shim shutting down")); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1_500).unref(); +}); diff --git a/clients/packages/hub-shim/tsconfig.json b/clients/packages/hub-shim/tsconfig.json new file mode 100644 index 00000000..dc133fb7 --- /dev/null +++ b/clients/packages/hub-shim/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "dev/**/*.ts", "../agentrun-client/src/**/*.ts"] +} diff --git a/clients/ui/.gitignore b/clients/ui/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /dev/null +++ b/clients/ui/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/clients/ui/README.md b/clients/ui/README.md new file mode 100644 index 00000000..74423daf --- /dev/null +++ b/clients/ui/README.md @@ -0,0 +1,12 @@ +# Konveyor Agentic Runs UI (prototype) + +Vite + React 18 + PatternFly 6 front-end over the hub-shim HTTP/WS API. It +consumes `@konveyor/agentic-client` straight from source via a Vite alias — +no build step for the core package. + +Prerequisites: the agentic-controller live on the cluster, and the hub-shim +running locally (`cd packages/hub-shim && npm run dev`), default +`http://127.0.0.1:7080` — override with `VITE_SHIM_URL`. + + cd ui && npm install + npm run dev # opens on http://localhost:5173; `npm run build` to verify diff --git a/clients/ui/index.html b/clients/ui/index.html new file mode 100644 index 00000000..34118f61 --- /dev/null +++ b/clients/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Konveyor Agentic Runs (prototype) + + +
+ + + diff --git a/clients/ui/package-lock.json b/clients/ui/package-lock.json new file mode 100644 index 00000000..51770fe0 --- /dev/null +++ b/clients/ui/package-lock.json @@ -0,0 +1,2061 @@ +{ + "name": "@konveyor/agentic-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@konveyor/agentic-ui", + "version": "0.1.0", + "dependencies": { + "@patternfly/react-core": "^6.2.2", + "@patternfly/react-icons": "^6.2.2", + "@patternfly/react-table": "^6.2.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "~5.8.3", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@patternfly/react-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.6.0.tgz", + "integrity": "sha512-MLYPcJvdDWHZqWcvEH2QhKtCg6APRfrDJ4o192/wc2LUaf4ViGVsVLKDMbaKCamhbGauv7KpffNCSy3fjRy4Bw==", + "license": "MIT", + "dependencies": { + "@patternfly/react-icons": "^6.6.0", + "@patternfly/react-styles": "^6.6.0", + "@patternfly/react-tokens": "^6.6.0", + "focus-trap": "7.6.6", + "react-dropzone": "^14.3.5", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, + "node_modules/@patternfly/react-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.6.0.tgz", + "integrity": "sha512-cDDUm4PFxqeBo4PS08xO1eFd6CMTOppn5RS4fs2hAgu/vRFkPXuL0zZCkxgRg5asd/W4olEN7WrIAbvjKbHKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, + "node_modules/@patternfly/react-styles": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.6.0.tgz", + "integrity": "sha512-u/HzJ48Cg6Nb12dIVbj4g31lMDVHPHVKRXkRuPBcuK6MQyAsxSfcyWVivW7Lga8TsNhFW26JWB5ay+IW5xpZ8A==", + "license": "MIT" + }, + "node_modules/@patternfly/react-table": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.6.0.tgz", + "integrity": "sha512-aOVSAbtXagey1xRI8oIkK3I/csh6S00p42qqOkh6G1s5vMZM7oVQH/Nvvq8gIwx4lZ3f1m7IkdD7My8nml0Bsw==", + "license": "MIT", + "dependencies": { + "@patternfly/react-core": "^6.6.0", + "@patternfly/react-icons": "^6.6.0", + "@patternfly/react-styles": "^6.6.0", + "@patternfly/react-tokens": "^6.6.0", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, + "node_modules/@patternfly/react-tokens": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.6.0.tgz", + "integrity": "sha512-oRUFYJAM9LKYTM9YRnd7rz3wB6ZoaBWit/Eu+EK22J4o5AhZRjLt0+ewXQ7I99dBP4c8BbnPEUFnxQYAroqNLg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001802", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", + "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/focus-trap": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", + "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.3.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/clients/ui/package.json b/clients/ui/package.json new file mode 100644 index 00000000..3cd37081 --- /dev/null +++ b/clients/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@konveyor/agentic-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Browser UI prototype (tackle2-ui-shaped) for konveyor agentic runs: PatternFly 6 over the hub-shim HTTP/WS API via @konveyor/agentic-client.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@patternfly/react-core": "^6.2.2", + "@patternfly/react-icons": "^6.2.2", + "@patternfly/react-table": "^6.2.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "~5.8.3", + "vite": "^6.3.5" + } +} diff --git a/clients/ui/src/App.tsx b/clients/ui/src/App.tsx new file mode 100644 index 00000000..caf41edb --- /dev/null +++ b/clients/ui/src/App.tsx @@ -0,0 +1,237 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Masthead, + MastheadBrand, + MastheadContent, + MastheadMain, + Page, + PageSection, + Title, + ToggleGroup, + ToggleGroupItem, +} from "@patternfly/react-core"; +import { ShimClient } from "@konveyor/agentic-client/transport-shim"; +import { errorMessage } from "./format"; +import { RunsPage } from "./components/RunsPage"; +import { RunDetailPage } from "./components/RunDetailPage"; +import { PlaybookRunsPage } from "./components/PlaybookRunsPage"; +import { PlaybookRunDetailPage } from "./components/PlaybookRunDetailPage"; +import { AgentsPage } from "./components/AgentsPage"; +import { SkillsPage } from "./components/SkillsPage"; +import { PlaybooksPage } from "./components/PlaybooksPage"; + +// Dev default: the local shim. Production (static build behind nginx): +// same-origin — nginx proxies /api (HTTP + WebSocket) to the gateway. +const SHIM_URL = + import.meta.env.VITE_SHIM_URL ?? + (import.meta.env.DEV ? "http://127.0.0.1:7080" : window.location.origin); + +type View = + | { kind: "list" } + | { kind: "detail"; runName: string; fromPlaybookRun?: string } + | { kind: "playbooks" } + | { kind: "playbookDetail"; name: string } + | { kind: "agents" } + | { kind: "skills" } + | { kind: "playbookLibrary" }; + +// ---------------------------------------------------- hash deep-link routing +// +// The hash is the single shareable source of truth (#/runs/, #/agents…). +// In-memory-only view context (fromPlaybookRun back-navigation) deliberately +// does NOT serialize: a deep-linked run detail backs out to the runs list. + +function viewToHash(view: View): string { + switch (view.kind) { + case "list": + return "#/runs"; + case "detail": + return `#/runs/${encodeURIComponent(view.runName)}`; + case "playbooks": + return "#/playbook-runs"; + case "playbookDetail": + return `#/playbook-runs/${encodeURIComponent(view.name)}`; + case "agents": + return "#/agents"; + case "skills": + return "#/skills"; + case "playbookLibrary": + return "#/playbooks"; + } +} + +function hashToView(hash: string): View { + // Hand-edited hashes can carry malformed percent-encoding (#/runs/100%); + // an unguarded decodeURIComponent would throw during the initial useState + // initializer — white screen. Fall back to the raw segment. + const safeDecode = (segment: string): string => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }; + const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean).map(safeDecode); + switch (parts[0]) { + case "runs": + return parts[1] ? { kind: "detail", runName: parts[1] } : { kind: "list" }; + case "playbook-runs": + return parts[1] ? { kind: "playbookDetail", name: parts[1] } : { kind: "playbooks" }; + case "agents": + return { kind: "agents" }; + case "skills": + return { kind: "skills" }; + case "playbooks": + return { kind: "playbookLibrary" }; + default: + return { kind: "list" }; + } +} + +export function App() { + const [view, setViewState] = useState(() => hashToView(window.location.hash)); + + // navigate() owns hash writes; the hashchange/popstate listener covers + // back/forward and hand-edited URLs. The serialize-compare guard breaks + // the loop the two would otherwise feed (navigate -> hashchange -> setView + // -> …) while preserving in-memory context (fromPlaybookRun) the hash + // deliberately drops. + const navigate = (next: View) => { + setViewState(next); + const hash = viewToHash(next); + if (window.location.hash !== hash) window.history.pushState(null, "", hash); + }; + + useEffect(() => { + const onHashChange = () => { + setViewState((current) => + viewToHash(current) === (window.location.hash || "#/runs") + ? current + : hashToView(window.location.hash), + ); + }; + window.addEventListener("hashchange", onHashChange); + window.addEventListener("popstate", onHashChange); + return () => { + window.removeEventListener("hashchange", onHashChange); + window.removeEventListener("popstate", onHashChange); + }; + }, []); + + // ShimClient validates the base URL eagerly — surface a bad VITE_SHIM_URL + // as an alert instead of a white screen. + const api = useMemo<{ client?: ShimClient; error?: string }>(() => { + try { + return { client: new ShimClient(SHIM_URL) }; + } catch (err) { + return { error: errorMessage(err) }; + } + }, []); + + const masthead = ( + + + + + Konveyor Agentic Runs{" "} + <span style={{ fontWeight: 400, opacity: 0.7 }}>(prototype)</span> + + + + + shim: {SHIM_URL} + + + ); + + // Top-level sections: the two run consoles, then the management catalog + // (Agents / Skills / Playbooks — Phase 1 management UX). + const nav: { text: string; id: string; kind: View["kind"]; view: View }[] = [ + { text: "Agent runs", id: "nav-runs", kind: "list", view: { kind: "list" } }, + { text: "Playbook runs", id: "nav-playbook-runs", kind: "playbooks", view: { kind: "playbooks" } }, + { text: "Agents", id: "nav-agents", kind: "agents", view: { kind: "agents" } }, + { text: "Skills", id: "nav-skills", kind: "skills", view: { kind: "skills" } }, + { text: "Playbooks", id: "nav-playbooks", kind: "playbookLibrary", view: { kind: "playbookLibrary" } }, + ]; + const onList = nav.some((item) => item.kind === view.kind); + + return ( + + {api.error || !api.client ? ( + + + {api.error ?? "no client"} + + + ) : ( + <> + {onList && ( + + + {nav.map((item) => ( + navigate(item.view)} + /> + ))} + + + )} + {view.kind === "list" ? ( + navigate({ kind: "detail", runName })} + onOpenPlaybookRun={(name) => navigate({ kind: "playbookDetail", name })} + /> + ) : view.kind === "playbooks" ? ( + navigate({ kind: "playbookDetail", name })} + /> + ) : view.kind === "agents" ? ( + + ) : view.kind === "skills" ? ( + + ) : view.kind === "playbookLibrary" ? ( + + ) : view.kind === "playbookDetail" ? ( + navigate({ kind: "playbooks" })} + onDeleted={() => navigate({ kind: "playbooks" })} + onRerun={(name) => navigate({ kind: "playbookDetail", name })} + onOpenRun={(runName) => + navigate({ kind: "detail", runName, fromPlaybookRun: view.name }) + } + /> + ) : ( + + navigate( + view.fromPlaybookRun + ? { kind: "playbookDetail", name: view.fromPlaybookRun } + : { kind: "list" }, + ) + } + onDeleted={() => + navigate( + view.fromPlaybookRun + ? { kind: "playbookDetail", name: view.fromPlaybookRun } + : { kind: "list" }, + ) + } + onRerun={(newRunName) => navigate({ kind: "detail", runName: newRunName })} + /> + )} + + )} + + ); +} diff --git a/clients/ui/src/app.css b/clients/ui/src/app.css new file mode 100644 index 00000000..f3eea0ed --- /dev/null +++ b/clients/ui/src/app.css @@ -0,0 +1,241 @@ +html, +body, +#root { + height: 100%; +} + +.masthead-shim { + font-size: 0.85rem; + opacity: 0.8; +} + +/* Make the chat section fill the remaining page height. */ +.run-detail-chat-section { + display: flex; + flex-direction: column; +} +.run-detail-chat-section .chat-panel { + flex: 1 1 auto; + min-height: 0; +} + +.chat-panel { + display: flex; + flex-direction: column; + min-height: 340px; + background: #fff; + border: 1px solid #d2d2d2; + border-radius: 8px; +} + +.chat-status { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid #ececec; + font-size: 0.875rem; + min-height: 2.5rem; +} +.chat-status-detail { + color: #6a6e73; + font-size: 0.8rem; +} + +.chat-log { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.chat-bubble { + max-width: 85%; + padding: 0.5rem 0.75rem; + border-radius: 8px; + white-space: pre-wrap; + word-break: break-word; +} +.chat-user { + align-self: flex-end; + background: #e7f1fa; +} +.chat-agent { + align-self: flex-start; + background: #f5f5f5; +} +.chat-thought { + align-self: flex-start; + background: #faf6ec; + font-style: italic; + color: #6a6e73; +} + +.chat-meta { + align-self: center; + color: #6a6e73; + font-size: 0.8rem; +} + +.chat-tool { + align-self: stretch; + border: 1px solid #ececec; + border-radius: 8px; + padding: 0.5rem 0.75rem; +} +.chat-tool-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} +.chat-tool-detail { + margin: 0.5rem 0 0; + padding: 0.5rem; + background: #f8f8f8; + border-radius: 4px; + font-size: 0.85rem; + overflow-x: auto; + white-space: pre-wrap; +} + +.chat-permission { + align-self: stretch; + border: 1px solid #f4c145; + background: #fdf7e7; + border-radius: 8px; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.chat-permission-title { + font-weight: 600; +} +.chat-permission-actions { + display: flex; + gap: 0.5rem; +} + +.inventory-source { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.85rem; + border: 1px solid #d2d2d2; + background: #f8f8f8; +} +.inventory-source-hub { + border-color: #a2d9a2; + background: #f3faf3; +} +.inventory-source-stub { + border-color: #f0ab00; + background: #fdf7e7; +} +.inventory-source-label { + font-weight: 500; +} +.inventory-source-endpoint { + color: #6a6e73; + font-size: 0.78rem; +} +.inventory-source .pf-v6-c-button { + margin-left: auto; +} + +.resolved-params { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.6rem 0.75rem; + border: 1px solid #d2d2d2; + border-radius: 4px; + background: #f8f8f8; + font-size: 0.85rem; +} +.resolved-param { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} +.resolved-param dt, +.resolved-param dd { + margin: 0; +} +.resolved-param-source { + color: #6a6e73; +} +.resolved-param-value { + color: #1e4f18; + font-family: monospace; + font-size: 0.8rem; + margin-left: 0.5rem; +} +.resolved-param-missing { + color: #a12800; + font-style: italic; +} + +.chat-diff { + border: 1px solid #d2d2d2; + border-radius: 4px; + background: #ffffff; + overflow: hidden; +} +.chat-diff-path { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + background: #f0f0f0; + border-bottom: 1px solid #d2d2d2; + font-size: 0.85rem; +} +.chat-diff-body { + margin: 0; + padding: 0.25rem 0; + font-size: 0.8rem; + line-height: 1.45; + overflow-x: auto; +} +.chat-diff-line { + padding: 0 0.5rem; + white-space: pre; +} +.chat-diff-sign { + display: inline-block; + width: 1rem; + user-select: none; + opacity: 0.7; +} +.chat-diff-add { + background: #e6f6e6; + color: #1e4f18; +} +.chat-diff-del { + background: #fbe7e7; + color: #7d1007; +} + +.chat-input-row { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid #ececec; + align-items: flex-start; +} +.chat-input-text { + flex: 1 1 auto; +} +.chat-input-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} diff --git a/clients/ui/src/components/AgentDesignerModal.tsx b/clients/ui/src/components/AgentDesignerModal.tsx new file mode 100644 index 00000000..f8f87df4 --- /dev/null +++ b/clients/ui/src/components/AgentDesignerModal.tsx @@ -0,0 +1,705 @@ +import { useEffect, useRef, useState } from "react"; +import { + Alert, + Button, + Checkbox, + Flex, + FlexItem, + Form, + FormGroup, + FormHelperText, + FormSelect, + FormSelectOption, + HelperText, + HelperTextItem, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + TextArea, + TextInput, +} from "@patternfly/react-core"; +import { + PARAM_NAME_PATTERN, + RESOURCE_NAME_MAX, + RESOURCE_NAME_PATTERN, +} from "@konveyor/agentic-client/contract"; +import type { + AgentParam, + AgentParamType, + AgentResource, + AgentResourceSpec, + LLMProvider, + SkillCard, + SkillCollection, +} from "@konveyor/agentic-client/contract"; +import type { ShimClient } from "@konveyor/agentic-client/transport-shim"; +import { errorMessage, truncate } from "../format"; +import { ReadyLabel, paramValueInvalidReason, useImageCatalog, useProviders } from "./sources"; + +const CUSTOM_IMAGE = "__custom__"; + +interface AgentDesignerModalProps { + api: ShimClient; + /** Undefined = create; set = edit (name locked, spec replaced on save). */ + agent?: AgentResource; + onClose: () => void; + onSaved: (name: string) => void; +} + +/** "1st", "2nd", "3rd", "4th" … */ +function ordinal(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return `${n}st`; + if (mod10 === 2 && mod100 !== 12) return `${n}nd`; + if (mod10 === 3 && mod100 !== 13) return `${n}rd`; + return `${n}th`; +} + +/** Muted "N models, primary: " summary for a provider checkbox label. */ +function providerModelsSummary(p: LLMProvider): string { + const models = p.spec.models ?? []; + const primary = models.find((m) => m.tier === "primary"); + const count = `${models.length} model${models.length === 1 ? "" : "s"}`; + return primary ? `${count}, primary: ${primary.name}` : count; +} + +interface ParamRow { + key: number; + name: string; + type: AgentParamType; + description: string; + default: string; + required: boolean; +} + +function rowsFrom(params: AgentParam[] | undefined): ParamRow[] { + return (params ?? []).map((p, i) => ({ + key: i, + name: p.name, + type: p.type ?? "string", + description: p.description ?? "", + default: p.default ?? "", + required: p.required ?? false, + })); +} + +/** Why a param row cannot be saved, or undefined when it can. */ +function paramRowInvalidReason(row: ParamRow, rows: ParamRow[]): string | undefined { + if (!row.name.trim()) return "Name is required."; + if (!PARAM_NAME_PATTERN.test(row.name)) { + return "Name must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (it becomes the KONVEYOR_PARAM_ env var)."; + } + if (rows.some((r) => r !== row && r.name === row.name)) return "Duplicate parameter name."; + if (row.type === "number" && row.default.trim()) { + const bad = paramValueInvalidReason({ name: row.name, type: "number" }, row.default); + if (bad) return `Default ${bad}.`; + } + return undefined; +} + +export function AgentDesignerModal({ api, agent, onClose, onSaved }: AgentDesignerModalProps) { + const isEdit = agent !== undefined; + + const [name, setName] = useState(agent?.metadata.name ?? ""); + const [prompt, setPrompt] = useState(agent?.spec.prompt ?? ""); + + // Image: a catalog FormSelect (with a custom escape hatch) when the catalog + // is non-empty, a plain TextInput otherwise. On edit start on the custom + // path with the value filled in; when the catalog arrives and knows the + // image, snap to that entry (once — and never after the user has touched + // the field, so a late catalog can't clobber an in-progress edit). + const catalog = useImageCatalog(api); + const [imageSelect, setImageSelect] = useState(isEdit ? CUSTOM_IMAGE : ""); + const [customImage, setCustomImage] = useState(agent?.spec.image ?? ""); + const imageTouched = useRef(false); + const imageInitDone = useRef(false); + useEffect(() => { + if (!agent || imageInitDone.current || imageTouched.current || catalog.length === 0) return; + imageInitDone.current = true; + if (catalog.some((e) => e.image === customImage)) { + setImageSelect(customImage); + } + }, [catalog, agent, customImage]); + const effectiveImage = + catalog.length > 0 && imageSelect !== CUSTOM_IMAGE ? imageSelect : customImage; + + // Providers: selection ORDER matters (the first ref drives the launcher's + // default model policy), so state is an ordered list, not a set. + const { providers, error: providersError } = useProviders(api); + const [providerRefs, setProviderRefs] = useState( + () => agent?.spec.providers?.map((p) => p.ref) ?? [], + ); + const toggleProvider = (ref: string, checked: boolean) => { + setProviderRefs((prev) => + checked ? [...prev.filter((r) => r !== ref), ref] : prev.filter((r) => r !== ref), + ); + }; + // Refs the agent declares but listProviders didn't return still need a row, + // so they stay visible and un-checkable. + const providerRows: { name: string; provider?: LLMProvider }[] = [ + ...providers.map((p) => ({ name: p.metadata.name ?? "", provider: p })), + ...providerRefs + .filter((ref) => !providers.some((p) => p.metadata.name === ref)) + .map((ref) => ({ name: ref })), + ]; + + // Skills: both groups optional individually, loudly warned when both empty. + const [cards, setCards] = useState(null); + const [collections, setCollections] = useState(null); + const [skillsError, setSkillsError] = useState(null); + const [cardRefs, setCardRefs] = useState( + () => agent?.spec.skillCards?.map((s) => s.ref) ?? [], + ); + const [collectionRefs, setCollectionRefs] = useState( + () => agent?.spec.skillCollections?.map((s) => s.ref) ?? [], + ); + useEffect(() => { + let disposed = false; + Promise.all([api.listSkillCards(), api.listSkillCollections()]) + .then(([cardList, collectionList]) => { + if (disposed) return; + setCards(cardList); + setCollections(collectionList); + }) + .catch((err) => { + if (!disposed) setSkillsError(errorMessage(err)); + }); + return () => { + disposed = true; + }; + }, [api]); + const toggleRef = ( + set: (fn: (prev: string[]) => string[]) => void, + ref: string, + checked: boolean, + ) => { + set((prev) => (checked ? [...prev.filter((r) => r !== ref), ref] : prev.filter((r) => r !== ref))); + }; + // Refs the agent declares but the managed-filtered lists didn't return still + // need a row, so they stay visible and un-checkable (same as providers). + const unlistedCardRefs = cardRefs.filter( + (ref) => !(cards ?? []).some((c) => c.metadata.name === ref), + ); + const unlistedCollectionRefs = collectionRefs.filter( + (ref) => !(collections ?? []).some((c) => c.metadata.name === ref), + ); + + // Params editor rows. + const [paramRows, setParamRows] = useState(() => rowsFrom(agent?.spec.params)); + const nextKey = useRef(paramRows.length); + const updateRow = (key: number, patch: Partial) => { + setParamRows((prev) => + prev.map((r) => { + if (r.key !== key) return r; + const next = { ...r, ...patch }; + // CRD CEL rule: a parameter with a default value cannot be required. + if (next.default.trim()) next.required = false; + return next; + }), + ); + }; + + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const trimmedName = name.trim(); + const nameInvalidReason = !isEdit + ? !trimmedName + ? "Name is required." + : trimmedName.length > RESOURCE_NAME_MAX + ? `Name must be at most ${RESOURCE_NAME_MAX} characters.` + : !RESOURCE_NAME_PATTERN.test(trimmedName) + ? "Name must be a DNS-1123 name: lowercase alphanumerics, '-' and '.', starting and ending alphanumeric." + : undefined + : undefined; + const imageMissing = !effectiveImage.trim(); + const noProviders = providerRefs.length === 0; + const paramsInvalid = paramRows.some((r) => paramRowInvalidReason(r, paramRows) !== undefined); + const noSkills = cardRefs.length === 0 && collectionRefs.length === 0; + + const canSave = + !submitting && !nameInvalidReason && !imageMissing && !noProviders && !paramsInvalid; + + const submit = async () => { + if (!canSave) return; + setSubmitting(true); + setSubmitError(null); + try { + const spec: AgentResourceSpec = { + image: effectiveImage.trim(), + providers: providerRefs.map((ref) => ({ ref })), + }; + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt) spec.prompt = trimmedPrompt; + if (cardRefs.length > 0) spec.skillCards = cardRefs.map((ref) => ({ ref })); + if (collectionRefs.length > 0) { + spec.skillCollections = collectionRefs.map((ref) => ({ ref })); + } + if (paramRows.length > 0) { + spec.params = paramRows.map((r) => { + const p: AgentParam = { name: r.name, type: r.type }; + if (r.description.trim()) p.description = r.description.trim(); + if (r.default.trim()) p.default = r.default.trim(); + if (r.required) p.required = true; + return p; + }); + } + const saved = isEdit + ? await api.updateAgent(agent.metadata.name ?? trimmedName, spec) + : await api.createAgent(trimmedName, spec); + onSaved(saved.metadata.name ?? trimmedName); + } catch (err) { + setSubmitError(errorMessage(err)); + setSubmitting(false); + } + }; + + return ( + { + if (!submitting) onClose(); + }} + aria-labelledby="agent-designer-title" + > + + + {submitError && ( + + {submitError} + + )} +
{ + e.preventDefault(); + void submit(); + }} + > + {!isEdit && ( + + setName(v)} + validated={name && nameInvalidReason ? "error" : "default"} + /> + {name && nameInvalidReason && ( + + + {nameInvalidReason} + + + )} + + )} + + + {catalog.length > 0 ? ( + <> + { + imageTouched.current = true; + setImageSelect(v); + }} + > + + {catalog.map((e) => ( + + ))} + + + {imageSelect === CUSTOM_IMAGE && ( + { + imageTouched.current = true; + setCustomImage(v); + }} + placeholder="registry.example.com/my-agent:tag" + /> + )} + + ) : ( + { + imageTouched.current = true; + setCustomImage(v); + }} + placeholder="registry.example.com/my-agent:tag" + /> + )} + + + + {imageMissing + ? "An image is required — it carries the agent runtime (ACP server on :4000/acp)." + : "Container image carrying the agent runtime (ACP server on :4000/acp)."} + + + + + + +