diff --git a/README.md b/README.md index 155397c..4f76b89 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 0000000..7d87f15 --- /dev/null +++ b/changes/unreleased/clients-reference-stack.yaml @@ -0,0 +1,8 @@ +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). diff --git a/clients/.gitignore b/clients/.gitignore new file mode 100644 index 0000000..51b1bee --- /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 0000000..55774d2 --- /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 0000000..316ab86 --- /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 0000000..e985327 --- /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 0000000..f6b74a6 --- /dev/null +++ b/clients/deploy/manifests/gateway.yaml @@ -0,0 +1,99 @@ +# 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: + - apiGroups: ["konveyor.io"] + resources: ["agentruns"] + verbs: ["get", "list", "watch", "create", "delete"] + - apiGroups: ["konveyor.io"] + resources: ["agents", "llmproviders"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +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 0000000..39dac41 --- /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 0000000..92aa9ac --- /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 0000000..dca4ff0 --- /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 0000000..3594612 --- /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 0000000..53cdec9 --- /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 0000000..20a6d33 --- /dev/null +++ b/clients/docs/DEMO.md @@ -0,0 +1,155 @@ +# 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. + +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 0000000..8d225b8 --- /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 0000000..a8734c0 --- /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 0000000..c6e48aa --- /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 0000000..e272e1f --- /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 0000000..6557a8b --- /dev/null +++ b/clients/hack/demo-up.sh @@ -0,0 +1,150 @@ +#!/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}" +HUB_SVC="${HUB_SVC:-tackle2-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)" +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 0000000..a85b870 --- /dev/null +++ b/clients/harness-goose/Dockerfile @@ -0,0 +1,44 @@ +# 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 0000000..df9d6c0 --- /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 0000000..43a0fb6 --- /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 0000000..1633c47 --- /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 0000000..be3fe4c --- /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 0000000..4358c7f --- /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 0000000..4d64ca2 --- /dev/null +++ b/clients/manifests/goose-bedrock.yaml @@ -0,0 +1,63 @@ +# Real goose harness wired to AWS Bedrock. +# 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: bedrock + namespace: konveyor-agents + annotations: + # POC contract: tells the harness which goose provider implementation + # this LLMProvider maps to. See simulate-controller.ts. + konveyor.io/goose-provider: aws_bedrock +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: Agent +metadata: + name: migration-analyzer-goose + namespace: konveyor-agents + labels: + konveyor.io/managed: "true" + annotations: + # Platform-resolved params (ADR 0005) — same contract as the mock agent. + konveyor.io/param-sources: | + {"repository": "konveyor.io/application-repository-url", + "branch": "konveyor.io/application-repository-branch"} + konveyor.io/credential-sources: | + {"git": "konveyor.io/application-identity"} +spec: + image: "goose-harness:dev" + prompt: | + You are a migration analysis agent. The application source is checked + out at /workspace. Answer questions about it concisely. + providers: + - ref: bedrock + params: + - name: repository + type: string + description: Git URL of the application repository + required: true + - name: branch + type: string + description: Branch to analyze + default: main diff --git a/clients/manifests/samples.yaml b/clients/manifests/samples.yaml new file mode 100644 index 0000000..b16426c --- /dev/null +++ b/clients/manifests/samples.yaml @@ -0,0 +1,130 @@ +# 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: v1 +kind: Secret +metadata: + name: git-credentials-coolstore + namespace: konveyor-agents +type: Opaque +stringData: + # Mock application identity (see hub-shim's application inventory). The + # platform mounts this via envFrom when an agent declares a credential + # sourced from konveyor.io/application-identity. + GIT_USERNAME: "coolstore-bot" + GIT_TOKEN: "not-a-real-token" +--- +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" + annotations: + # Platform-resolved params (ADR 0005): the platform fills these from + # the selected application; the UI hides them from the create form. + konveyor.io/param-sources: | + {"repository": "konveyor.io/application-repository-url", + "branch": "konveyor.io/application-repository-branch"} + konveyor.io/credential-sources: | + {"git": "konveyor.io/application-identity"} +spec: + # In the real system this is the harness image (goose + toolchains). + # The controller simulator substitutes the mock ACP harness image. + 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 of the application repository + required: true + - name: branch + type: string + description: Branch to analyze + default: main +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: analyze-java-ee + namespace: konveyor-agents +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 +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 +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 0000000..1d04878 --- /dev/null +++ b/clients/packages/agentic-client/dev/selfcheck.ts @@ -0,0 +1,220 @@ +/** + * 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")), + listApplications: () => Promise.reject(new Error("unused")), + listRuns: () => Promise.reject(new Error("unused")), + createRun: () => Promise.reject(new Error("unused")), + deleteRun: () => 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 0000000..faf9d31 --- /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 0000000..9c35276 --- /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 0000000..6d52a68 --- /dev/null +++ b/clients/packages/agentic-client/src/acp/index.ts @@ -0,0 +1,617 @@ +/** + * 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; +const DEFAULT_CWD = "/workspace"; + +// ------------------------------------------------------------------ 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 0000000..5432720 --- /dev/null +++ b/clients/packages/agentic-client/src/contract/index.ts @@ -0,0 +1,418 @@ +/** + * 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. + */ + +// ---------------------------------------------------------------- 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; +} + +// ---------------------------------------------------------------- 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 — opaque to this client. */ + env?: unknown; + /** Pod envFrom passthrough — opaque to this client. */ + envFrom?: unknown; +} + +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"; + +/** 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 (unset once the run is terminal). */ + 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 }[]; + }; + 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 }[]; +} + +/** 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[] }; +} + +// ------------------------------------------------------------ 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); +} + +// ------------------------------------------------- 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 (Hub / hub-shim) 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. + * (Annotation today; graduates to an AgentParam field once proven.) + */ +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. + */ +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. + * NOTE: this is a reference, not a usable secret: materializing a Hub + * identity into the sandbox is the open design question (see ADR 0005). + */ + identity?: { name: string }; + /** + * The materialized form of `identity`: a k8s Secret the platform mounts + * via envFrom. Today the shim bridges known Hub identities to a + * pre-created Secret; production Hub would decrypt its vault identity into + * the sandbox itself. Unset when no bridge exists yet. + */ + identitySecret?: string; +} + +// ----------------------------------------------------------------- RunApi + +/** Input for RunApi.createRun — params as a plain map, mapped by the transport. */ +export interface CreateRunInput { + agentRef: string; + params?: Record; + instructions?: string; + /** + * Application whose data the platform uses to resolve sourced params and + * credentials. Caller-supplied param values always win over resolution. + */ + applicationRef?: 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; + /** Platform application inventory (for resolving sourced params). */ + listApplications(): Promise; + listRuns(): Promise; + listPlaybookRuns(): Promise; + getPlaybookRun(name: string): Promise; + createRun(input: CreateRunInput): Promise; + getRun(name: string): Promise; + deleteRun(name: string): Promise; +} + +// ---------------------------------------------------------------- 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 0000000..06f0b05 --- /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 0000000..6dd9a61 --- /dev/null +++ b/clients/packages/agentic-client/src/transport-shim/index.ts @@ -0,0 +1,140 @@ +/** + * 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) + */ +import type { + AgentPlaybookRun, + AgentResource, + AgentRun, + Application, + CreateRunInput, + RunApi, +} 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 { + /** 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"); + } + + 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)}`); + } + + listPlaybookRuns(): Promise { + return this.json("GET", "/api/agentplaybookruns"); + } + + getPlaybookRun(name: string): Promise { + return this.json("GET", `/api/agentplaybookruns/${encodeURIComponent(name)}`); + } + + async deleteRun(name: string): Promise { + await this.send("DELETE", `/api/agentruns/${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 0000000..c3cc3c0 --- /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 0000000..d8000d7 --- /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 0000000..1fa4c56 --- /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 0000000..e3c1a34 --- /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 0000000..adc10a4 --- /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 0000000..2e3910e --- /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 0000000..dc135b8 --- /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 0000000..e939900 --- /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 0000000..818da41 --- /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 0000000..13e9d5a --- /dev/null +++ b/clients/packages/agentrun-client/src/types.ts @@ -0,0 +1,224 @@ +/** + * 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; +} + +// ------------------------------------------------------------------- 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 0000000..233f211 --- /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 0000000..a808d05 --- /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 0000000..e6bd93d --- /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 0000000..cc72e53 --- /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 0000000..af14375 --- /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 0000000..c5f86dd --- /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 0000000..89f285d --- /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/server.ts b/clients/packages/hub-shim/src/server.ts new file mode 100644 index 0000000..7bf7f52 --- /dev/null +++ b/clients/packages/hub-shim/src/server.ts @@ -0,0 +1,810 @@ +/** + * 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-") + * GET /api/agentruns/:name -> 200 AgentRun | 404 + * DELETE /api/agentruns/:name -> 204 | 404 + * WS /api/agentruns/:name/acp -> bidirectional pipe to the pod + * + * 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 AgentRun, + type AgentRunModelSelection, + type AgentRunSpec, + type EnvFromSource, + type LLMProvider, +} from "../../agentrun-client/src/types.js"; +import { + CREDENTIAL_SOURCES_ANNOTATION, + MANAGED_LABEL, + PARAM_SOURCES_ANNOTATION, + SOURCE_APPLICATION_IDENTITY, + SOURCE_APPLICATION_REPOSITORY_BRANCH, + SOURCE_APPLICATION_REPOSITORY_URL, + parseSourcesAnnotation, + type Application, +} from "../../agentic-client/src/contract/index.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); + +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 Agents that opt into platform management. Other + * resource lists are unfiltered; get-by-name is never filtered. + */ +const LIST_LABEL_SELECTORS: Record = { + [PLURALS.Agent]: `${MANAGED_LABEL}=true`, +}; + +/** + * 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(/\/+$/, ""); + +/** + * Bridges a Hub source-control Identity to a pre-created k8s Secret — the + * STUB for the one thing Hub doesn't expose over REST: the decrypted + * credential. Production Hub would materialize its vault identity into the + * sandbox itself; until then, known identities map to a Secret here. + */ +const IDENTITY_SECRET_BRIDGE: Record = { + "coolstore-git": "git-credentials-coolstore", +}; + +/** Offline fallback when HUB_URL is unset or the Hub is unreachable. */ +const STUB_APPLICATIONS: Application[] = [ + { + id: "coolstore", + name: "Coolstore (stub — Hub unavailable)", + repository: { url: "https://github.com/konveyor-ecosystem/coolstore.git", branch: "main" }, + identitySecret: "git-credentials-coolstore", + }, +]; + +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" } }); + 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, + identitySecret: idName ? IDENTITY_SECRET_BRIDGE[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"); + } +} + +interface CreateRunBody { + agentRef: string; + params?: Record; + instructions?: string; + applicationRef?: string; +} + +/** + * What the platform contributes to a run beyond the caller's input: + * param values resolved from the selected application, and credential + * Secrets mounted via envFrom. + */ +interface ResolvedSources { + params: Record; + envFrom: EnvFromSource[]; +} + +/** + * Resolves an Agent's declared param/credential sources from the selected + * application — the Hub-side half of the param-sources contract (ADR 0005). + * + * Fail-open takes precedence over every other rule: an unrecognized source + * identifier, or an annotation entry naming a param the Agent does not + * declare, is skipped and the param reverts to caller-supplied semantics. + * Throws (-> 400) only for an unknown applicationRef, or for a REQUIRED + * param with a RECOGNIZED source that the application cannot supply. + */ +async function resolveSources(input: CreateRunBody): Promise { + const resolved: ResolvedSources = { params: {}, envFrom: [] }; + if (!input.applicationRef) return resolved; + + const app = (await getApplications()).applications.find((a) => a.id === input.applicationRef); + if (!app) { + badRequest( + `unknown applicationRef "${input.applicationRef}" — GET /api/applications lists the inventory`, + ); + } + let agent: Agent; + try { + agent = (await getCustom(PLURALS.Agent, "Agent", input.agentRef)) as Agent; + } catch (err) { + if (k8sStatusCode(err) === 404) badRequest(`unknown agentRef "${input.agentRef}"`); + throw err; + } + + const sourceValues: Record = { + [SOURCE_APPLICATION_REPOSITORY_URL]: app.repository?.url, + [SOURCE_APPLICATION_REPOSITORY_BRANCH]: app.repository?.branch, + }; + const paramSources = parseSourcesAnnotation(agent, PARAM_SOURCES_ANNOTATION); + for (const [name, source] of Object.entries(paramSources)) { + if (input.params?.[name] !== undefined) continue; // caller wins + if (!agent.spec.params?.some((p) => p.name === name)) { + // Stale annotation (e.g. the param was renamed). Injecting it would + // hand the sandbox a KONVEYOR_PARAM_* the agent never declared. + log(`param-sources: "${name}" is not declared in spec.params — ignoring`); + continue; + } + if (!Object.prototype.hasOwnProperty.call(sourceValues, source)) { + log(`param-sources: unrecognized source "${source}" for param "${name}" — fail open`); + continue; + } + const value = sourceValues[source]; + if (value !== undefined) { + resolved.params[name] = value; + } else if (agent.spec.params?.some((p) => p.name === name && p.required && !p.default)) { + badRequest( + `required param "${name}" resolves from ${source}, but application ` + + `"${app.id}" has no value for it — supply the param explicitly`, + ); + } + } + + const credentialSources = parseSourcesAnnotation(agent, CREDENTIAL_SOURCES_ANNOTATION); + for (const [name, source] of Object.entries(credentialSources)) { + if (source !== SOURCE_APPLICATION_IDENTITY) { + log(`credential-sources: unrecognized source "${source}" for "${name}" — fail open`); + continue; + } + if (app.identitySecret) { + resolved.envFrom.push({ secretRef: { name: app.identitySecret } }); + } else { + // Credentials are best-effort: apps without an identity (public + // repos) still run. The agent sees no creds and acts accordingly. + log(`credential "${name}": application "${app.id}" has no identity secret — skipping`); + } + } + return resolved; +} + +/** + * 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 and the run reverts to the harness's own defaults. + */ +async function resolveModels( + agentRef: string, +): Promise<{ models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }> { + const empty = { models: [] as AgentRunModelSelection[], envFrom: [] as EnvFromSource[] }; + let agent: Agent; + try { + agent = (await getCustom(PLURALS.Agent, "Agent", agentRef)) as Agent; + } catch (err) { + // Unknown agent: let createAgentRun proceed and the controller report it. + if (k8sStatusCode(err) === 404) return empty; + throw err; + } + const providerRef = agent.spec.providers?.[0]?.ref; + if (!providerRef) return empty; + + let provider: LLMProvider; + try { + provider = (await getCustom(PLURALS.LLMProvider, "LLMProvider", providerRef)) as LLMProvider; + } catch (err) { + if (k8sStatusCode(err) === 404) { + log(`agent "${agentRef}" 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 } }] + : []; + log( + `model: ${providerRef}/${model} for agent "${agentRef}"` + + (secretName ? ` (+creds secret ${secretName})` : ""), + ); + return { models, envFrom }; +} + +/** 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?}"); + } + const body = raw as Record; + if (typeof body.agentRef !== "string" || body.agentRef.trim() === "") { + badRequest("agentRef is required and must be a non-empty string"); + } + let params: Record | undefined; + if (body.params !== undefined) { + if (!body.params || typeof body.params !== "object" || Array.isArray(body.params)) { + badRequest("params must be an object of string values"); + } + params = {}; + 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; + } + } + if (body.instructions !== undefined && typeof body.instructions !== "string") { + badRequest("instructions must be a string"); + } + if (body.applicationRef !== undefined && typeof body.applicationRef !== "string") { + badRequest("applicationRef must be a string"); + } + return { + agentRef: body.agentRef, + params, + instructions: body.instructions as string | undefined, + applicationRef: body.applicationRef as string | undefined, + }; +} + +async function handleApi( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string, +): Promise { + const method = req.method ?? "GET"; + + 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, + }); + } + + 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 sources: ResolvedSources; + let modelSel: { models: AgentRunModelSelection[]; envFrom: EnvFromSource[] }; + try { + input = parseCreateRunBody(await readJsonBody(req)); + sources = await resolveSources(input); + modelSel = await resolveModels(input.agentRef); + } catch (err) { + // Only caller faults are 400. resolveSources/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)); + } + const spec: AgentRunSpec = { agentRef: input.agentRef }; + const params = { ...sources.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; + // Application credentials (git identity) + the LLM provider's credential + // secret both ride envFrom. + const envFrom = [...sources.envFrom, ...modelSel.envFrom]; + if (envFrom.length > 0) spec.envFrom = envFrom; + const run = await runClient.createAgentRun(spec, { generateName: "ui-" }); + 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}`); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const pathname = url.pathname; + + 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"); + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }); + res.end(); + return; + } + + handleApi(req, res, pathname).catch((err: unknown) => { + const status = k8sStatusCode(err) ?? 500; + warn(`${req.method} ${pathname} failed: ${errorMessage(err)}`); + if (!res.headersSent) sendError(res, status, errorMessage(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) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const match = /^\/api\/agentruns\/([^/]+)\/acp$/.exec(url.pathname); + if (!match) { + socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + const runName = decodeURIComponent(match[1]); + // 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/{agents,llmproviders,skillcards,skillcollections}[/:name] | GET|POST /api/agentruns | GET|DELETE /api/agentruns/:name | WS /api/agentruns/:name/acp`, + ); +}); + +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 0000000..dc133fb --- /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 0000000..f4e2c6d --- /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 0000000..74423da --- /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 0000000..34118f6 --- /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 0000000..51770fe --- /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 0000000..3cd3708 --- /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 0000000..e5a98ad --- /dev/null +++ b/clients/ui/src/App.tsx @@ -0,0 +1,129 @@ +import { 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"; + +// 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 }; + +export function App() { + const [view, setView] = useState({ kind: "list" }); + + // 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} + + + ); + + const onList = view.kind === "list" || view.kind === "playbooks"; + + return ( + + {api.error || !api.client ? ( + + + {api.error ?? "no client"} + + + ) : ( + <> + {onList && ( + + + setView({ kind: "list" })} + /> + setView({ kind: "playbooks" })} + /> + + + )} + {view.kind === "list" ? ( + setView({ kind: "detail", runName })} + onOpenPlaybookRun={(name) => setView({ kind: "playbookDetail", name })} + /> + ) : view.kind === "playbooks" ? ( + setView({ kind: "playbookDetail", name })} + /> + ) : view.kind === "playbookDetail" ? ( + setView({ kind: "playbooks" })} + onOpenRun={(runName) => + setView({ kind: "detail", runName, fromPlaybookRun: view.name }) + } + /> + ) : ( + + setView( + view.fromPlaybookRun + ? { kind: "playbookDetail", name: view.fromPlaybookRun } + : { kind: "list" }, + ) + } + /> + )} + + )} + + ); +} diff --git a/clients/ui/src/app.css b/clients/ui/src/app.css new file mode 100644 index 0000000..f3eea0e --- /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/ChatPanel.tsx b/clients/ui/src/components/ChatPanel.tsx new file mode 100644 index 0000000..394bce5 --- /dev/null +++ b/clients/ui/src/components/ChatPanel.tsx @@ -0,0 +1,577 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Alert, + AlertActionLink, + Button, + ExpandableSection, + Label, + Spinner, + TextArea, +} from "@patternfly/react-core"; +import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; +import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; +import { AcpSession } from "@konveyor/agentic-client/acp"; +import type { + PermissionOutcome, + PermissionRequest, + SessionUpdate, + ToolCallDiff, +} from "@konveyor/agentic-client/acp"; +import { isTerminalPhase, waitForRunning } from "@konveyor/agentic-client/contract"; +import type { ShimClient } from "@konveyor/agentic-client/transport-shim"; +import { errorMessage } from "../format"; + +// ------------------------------------------------------------- chat model + +interface UserItem { + kind: "user"; + id: number; + text: string; +} +interface AgentItem { + kind: "agent"; + id: number; + text: string; +} +interface ThoughtItem { + kind: "thought"; + id: number; + text: string; +} +interface ToolItem { + kind: "tool"; + id: number; + toolCallId: string; + title: string; + status: string; + detail: string; +} +interface PermissionItem { + kind: "permission"; + id: number; + title?: string; + /** File modifications to preview before answering (ACP diff blocks). */ + diffs?: ToolCallDiff[]; + options: PermissionRequest["options"]; + /** optionId chosen by the user, or "cancelled". Unset while pending. */ + chosen?: string; +} +interface StopItem { + kind: "stop"; + id: number; + stopReason: string; +} +interface ErrorItem { + kind: "error"; + id: number; + message: string; +} + +type ChatItem = UserItem | AgentItem | ThoughtItem | ToolItem | PermissionItem | StopItem | ErrorItem; + +type ConnState = + | { kind: "waiting"; message: string } + | { kind: "connecting" } + | { kind: "connected"; sessionId: string } + | { kind: "disconnected" } + | { kind: "failed"; message: string } + | { kind: "finished"; phase: string }; + +// -------------------------------------------------- session/update mapping + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +/** Text of an ACP content block ({type:"text", text}). */ +function contentText(content: unknown): string { + if (isRecord(content) && content.type === "text" && typeof content.text === "string") { + return content.text; + } + return ""; +} + +/** Text carried by tool_call_update.content: [{type:"content", content:{...}}]. */ +function toolUpdateText(content: unknown): string { + if (!Array.isArray(content)) return ""; + return content + .map((c) => (isRecord(c) && c.type === "content" ? contentText(c.content) : "")) + .filter(Boolean) + .join("\n"); +} + +/** Pure reducer from an ACP session/update onto the chat item list. */ +function reduceUpdate(items: ChatItem[], u: SessionUpdate, nextId: () => number): ChatItem[] { + switch (u.sessionUpdate) { + case "agent_message_chunk": { + const text = contentText(u.content); + if (!text) return items; + const last = items[items.length - 1]; + if (last && last.kind === "agent") { + return [...items.slice(0, -1), { ...last, text: last.text + text }]; + } + return [...items, { kind: "agent", id: nextId(), text }]; + } + case "agent_thought_chunk": { + const text = contentText(u.content); + if (!text) return items; + const last = items[items.length - 1]; + if (last && last.kind === "thought") { + return [...items.slice(0, -1), { ...last, text: last.text + text }]; + } + return [...items, { kind: "thought", id: nextId(), text }]; + } + case "tool_call": { + return [ + ...items, + { + kind: "tool", + id: nextId(), + toolCallId: str(u.toolCallId), + title: str(u.title) || "Tool call", + status: str(u.status) || "pending", + detail: toolUpdateText(u.content), + }, + ]; + } + case "tool_call_update": { + const toolCallId = str(u.toolCallId); + let idx = -1; + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i]; + if (it && it.kind === "tool" && it.toolCallId === toolCallId) { + idx = i; + break; + } + } + if (idx < 0) return items; + const tool = items[idx] as ToolItem; + const extra = toolUpdateText(u.content); + const next: ToolItem = { + ...tool, + status: str(u.status) || tool.status, + title: str(u.title) || tool.title, + detail: extra ? (tool.detail ? `${tool.detail}\n${extra}` : extra) : tool.detail, + }; + return [...items.slice(0, idx), next, ...items.slice(idx + 1)]; + } + default: + // plan / available_commands_update / etc. — out of prototype scope + return items; + } +} + +// ----------------------------------------------------------------- panel + +interface ChatPanelProps { + api: ShimClient; + runName: string; +} + +export function ChatPanel({ api, runName }: ChatPanelProps) { + const [conn, setConn] = useState({ kind: "waiting", message: "checking run status…" }); + const [items, setItems] = useState([]); + const [session, setSession] = useState(null); + const [input, setInput] = useState(""); + const [turnActive, setTurnActive] = useState(false); + const [attempt, setAttempt] = useState(0); // bumped by Retry / Reconnect + + const idRef = useRef(0); + const sessionRef = useRef(null); + const lastSessionIdRef = useRef(null); + const permissionResolvers = useRef(new Map void>()); + const logRef = useRef(null); + + const nextId = () => ++idRef.current; + + const pushItem = (item: ChatItem) => setItems((prev) => [...prev, item]); + + const handleUpdate = useCallback((u: SessionUpdate) => { + setItems((prev) => reduceUpdate(prev, u, () => ++idRef.current)); + }, []); + + // Render permission asks inline; the returned promise resolves when the + // user clicks an option (see choosePermission). + const handlePermission = useCallback((r: PermissionRequest): Promise => { + return new Promise((resolve) => { + const id = ++idRef.current; + permissionResolvers.current.set(id, resolve); + setItems((prev) => [ + ...prev, + { + kind: "permission", + id, + title: r.toolCall?.title, + diffs: r.toolCall?.diffs, + options: r.options, + }, + ]); + }); + }, []); + + const choosePermission = (id: number, optionId: string | null) => { + const resolve = permissionResolvers.current.get(id); + if (!resolve) return; + permissionResolvers.current.delete(id); + resolve( + optionId + ? { outcome: { outcome: "selected", optionId } } + : { outcome: { outcome: "cancelled" } }, + ); + setItems((prev) => + prev.map((it) => + it.id === id && it.kind === "permission" ? { ...it, chosen: optionId ?? "cancelled" } : it, + ), + ); + }; + + // Connect flow: wait for Running (status line), open the shim's ACP tunnel, + // then new-session (or load-session to replay history after a reconnect). + useEffect(() => { + let disposed = false; + const abort = new AbortController(); + let localSession: AcpSession | null = null; + + const connect = async () => { + setConn({ kind: "waiting", message: "checking run status…" }); + const current = await api.getRun(runName); + const phase = current.status?.phase ?? "Pending"; + if (isTerminalPhase(phase)) { + setConn({ kind: "finished", phase }); + return; + } + await waitForRunning(api, runName, { + signal: abort.signal, + onPhase: (p, elapsedMs) => { + if (!disposed) { + setConn({ + kind: "waiting", + message: `waiting for sandbox (${p}, ${Math.round(elapsedMs / 1000)}s)…`, + }); + } + }, + }); + if (disposed) return; + setConn({ kind: "connecting" }); + localSession = await AcpSession.connect({ + url: api.acpUrl(runName), + callbacks: { onUpdate: handleUpdate, onPermissionRequest: handlePermission }, + }); + if (disposed) { + void localSession.close(); + return; + } + sessionRef.current = localSession; + localSession.onClosed(() => { + if (!disposed) { + setConn({ kind: "disconnected" }); + setSession(null); + } + }); + // Prefer resuming the previous session after a drop — the agent + // replays its history as session/update notifications. + let sessionId: string; + const prior = lastSessionIdRef.current; + if (prior && localSession.loadSessionSupported) { + setItems([]); // the replay repopulates the transcript + try { + await localSession.loadSession(prior); + sessionId = prior; + } catch { + sessionId = await localSession.newSession(); + } + } else { + sessionId = await localSession.newSession(); + } + lastSessionIdRef.current = sessionId; + if (!disposed) { + setSession(localSession); + setConn({ kind: "connected", sessionId }); + } + }; + + connect().catch((err) => { + if (!disposed) setConn({ kind: "failed", message: errorMessage(err) }); + }); + + return () => { + disposed = true; + abort.abort(); + const s = sessionRef.current ?? localSession; + sessionRef.current = null; + if (s) void s.close(); + setSession(null); + setTurnActive(false); + }; + }, [api, runName, attempt, handleUpdate, handlePermission]); + + // Keep the transcript pinned to the bottom as updates stream in. + useEffect(() => { + const el = logRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [items, conn]); + + const send = async () => { + const text = input.trim(); + const s = session; + if (!text || !s || turnActive) return; + setInput(""); + pushItem({ kind: "user", id: nextId(), text }); + setTurnActive(true); + try { + const stopReason = await s.prompt(text); + pushItem({ kind: "stop", id: nextId(), stopReason }); + } catch (err) { + pushItem({ kind: "error", id: nextId(), message: errorMessage(err) }); + } finally { + setTurnActive(false); + } + }; + + const reconnectLabel = lastSessionIdRef.current ? "Reconnect" : "Retry"; + + return ( +
+
+ {conn.kind === "waiting" && ( + <> + {conn.message} + + )} + {conn.kind === "connecting" && ( + <> + connecting to ACP… + + )} + {conn.kind === "connected" && ( + <> + + session {conn.sessionId} + + )} + {conn.kind === "finished" && ( + + )} + {conn.kind === "disconnected" && ( + setAttempt((a) => a + 1)}> + {reconnectLabel} + + } + /> + )} + {conn.kind === "failed" && ( + setAttempt((a) => a + 1)}> + {reconnectLabel} + + } + /> + )} +
+ +
+ {items.length === 0 && conn.kind === "connected" && ( +
Connected — send a message to start the turn.
+ )} + {items.map((item) => ( + + ))} +
+ +
+
+