From 5e01103b248c0f74216ca5f8d16376f0ffb583b2 Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Sun, 7 Jun 2026 18:28:09 -0400 Subject: [PATCH 1/3] Gemini CLI example --- demos/gemini-cli-multiplex/README.md | 124 ++++ .../gemini-cli-multiplex.yaml.tmpl | 154 +++++ demos/gemini-cli-multiplex/ui/index.html | 424 ++++++++++++++ demos/gemini-cli-multiplex/ui/server.go | 540 ++++++++++++++++++ .../gemini-cli-multiplex/workload/Dockerfile | 30 + demos/gemini-cli-multiplex/workload/run.sh | 51 ++ hack/install-ate.sh | 1 + hack/install-demo-gemini-cli-multiplex.sh | 100 ++++ 8 files changed, 1424 insertions(+) create mode 100644 demos/gemini-cli-multiplex/README.md create mode 100644 demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl create mode 100644 demos/gemini-cli-multiplex/ui/index.html create mode 100644 demos/gemini-cli-multiplex/ui/server.go create mode 100644 demos/gemini-cli-multiplex/workload/Dockerfile create mode 100644 demos/gemini-cli-multiplex/workload/run.sh create mode 100644 hack/install-demo-gemini-cli-multiplex.sh diff --git a/demos/gemini-cli-multiplex/README.md b/demos/gemini-cli-multiplex/README.md new file mode 100644 index 000000000..252bdc852 --- /dev/null +++ b/demos/gemini-cli-multiplex/README.md @@ -0,0 +1,124 @@ +# Gemini CLI Multiplex Demo + +A demo of three Gemini-CLI-driven agents sharing two Agent Substrate pods. Substrate suspends idle agents and resumes them on demand, so the cluster runs *fewer pods than agents*. + +> [!NOTE] +> This demo intentionally provisions **two pods for three agents** to exercise substrate's suspend/resume path. The same pattern scales — ten agents on three pods, a hundred agents on twenty. + +## What this shows + +- Three Gemini CLI agents (`luna`, `mars`, `orion`) registered as Substrate actors. +- A `WorkerPool` of two pods. +- A small web UI that drives "give a task" against random idle agents and renders the queued/running/completed badge state per agent. +- Substrate handles the hard parts: state snapshot on suspend, scheduling decisions, resume-correctness when a pod becomes available. + +## Audience + +This guide assumes you know Kubernetes and the general shape of agent runtimes (autonomy + LLM API access). It does **not** assume prior Substrate experience. + +## Prerequisites + +- A Kubernetes cluster with **Agent Substrate** installed (`./hack/install-ate.sh` from this repo's root). +- `kubectl` configured against that cluster (the dashboard uses the operator's kubeconfig via [`client-go`](https://github.com/kubernetes/client-go) for pod-log reads). +- Network reach to the substrate **ateapi** gRPC service (`ateapi.ate-system:8080`). When running the dashboard from outside the cluster, port-forward it in a separate terminal and keep it running for the lifetime of the demo: + ```bash + # Terminal 1: ateapi port-forward + kubectl port-forward svc/ateapi 8080:8080 -n ate-system + ``` +- A **Gemini API key** from Google AI Studio (the agents call Gemini via `@google/gemini-cli`). +- A GCS bucket for substrate state snapshots (configured during Substrate install). +- `KO_DOCKER_REPO` set to a registry you can push to (e.g. `gcr.io/${PROJECT_ID}/ate-images`, same as `hack/ate-dev-env.sh.example`). The deploy step builds and pushes the workload image there with a sha256-pinned reference. +- `docker buildx` (the deploy function builds the workload image — a Dockerfile-based Node + `@google/gemini-cli` wrapper, not a Go binary, so `ko` doesn't apply for the workload itself). + +## Components + +| Path | Purpose | +|---|---| +| `demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl` | Namespace, WorkerPool, ActorTemplates in a single envsubst template | +| `hack/install-demo-gemini-cli-multiplex.sh` | Sourced by `install-ate.sh`; registers `--deploy-demo-gemini-cli-multiplex` and `--delete-demo-gemini-cli-multiplex` | +| `demos/gemini-cli-multiplex/workload/` | The agent container image source (Dockerfile + entrypoint that wires Gemini CLI; built and pushed by the deploy step) | +| `demos/gemini-cli-multiplex/ui/` | Static dashboard (`index.html` + `server.go`) that talks to the cluster | + +## How to Run + +### 1. Deploy the demo + +From the repo root, with your Gemini key and substrate bucket name in the environment: + +```bash +GEMINI_API_KEY=... \ +BUCKET_NAME=your-substrate-bucket \ + ./hack/install-ate.sh --deploy-demo-gemini-cli-multiplex +``` + +This creates the `gemini-cli-multiplex-demo` namespace, a 2-pod `WorkerPool`, and three `ActorTemplate` objects named `luna`, `mars`, `orion`. Under the hood, the deploy function builds the workload image with `docker buildx`, pushes it to `${KO_DOCKER_REPO}/gemini-cli-multiplex-demo-workload`, resolves the pushed sha256 digest, and substitutes the digest-pinned reference plus `GEMINI_API_KEY` and `BUCKET_NAME` into the manifest template at apply time. + +The model is `gemini-2.5-flash` by default; override per-template by editing the `GEMINI_MODEL` env in `gemini-cli-multiplex.yaml.tmpl` before deploying. + +Check that everything is running as expected: + +```bash +# k8s-native resources (these work with plain kubectl) +kubectl get pods,workerpool,actortemplate -n gemini-cli-multiplex-demo + +# Substrate-native (uses the kubectl-ate plugin against ateapi) +kubectl ate get actors +kubectl ate get workers +``` + +### 2. Start the dashboard + +Make sure the ateapi port-forward from the [Prerequisites](#prerequisites) is still running, then: + +```bash +cd demos/gemini-cli-multiplex/ui +PORT=8090 ATEAPI_ADDR=localhost:8080 go run . +``` + +Or build a binary: + +```bash +cd demos/gemini-cli-multiplex/ui +go build -o ui-server . +PORT=8090 ATEAPI_ADDR=localhost:8080 ./ui-server +``` + +Either way, the UI is served on `http://localhost:8090` (or whatever `PORT` you pick — pick something that doesn't collide with the ateapi port-forward). + +Env vars: + +| Var | Default | Purpose | +|---|---|---| +| `PORT` | `8080` | TCP port the dashboard binds (pick `≠ ATEAPI_ADDR`'s port when both run on the same host). | +| `ATEAPI_ADDR` | `localhost:8080` | Address of the substrate ateapi gRPC service. | +| `DEMO_NAMESPACE` | `gemini-cli-multiplex-demo` | Kubernetes namespace the dashboard filters to and reads pod logs from. | + +`GET /healthz` reports whether the kube client picked up a cluster context (`logs:true|false`) — useful for quick smoke-tests after starting the server. + +### 3. Drive the demo + +Click "Give a task". The UI picks a random idle agent and creates a task for it. Watch: + +- Badge flips to `queued` (the agent has work but isn't bound to a pod yet). +- Substrate finds a free pod and binds the agent. Badge flips to `running`. +- The agent calls Gemini, writes a result, exits. Badge flips to `completed`. +- Substrate notices the inactivity and suspends the agent after a short idle window. +- The released pod becomes available for the next queued task on a different agent. + +With three agents and two pods, the third agent stays suspended (state snapshotted) until a pod opens up. + +## Upstream blockers worked around for this demo + +Same upstream Substrate issues as the Claude Code variant. Each will be addressed by a separate upstream fix PR. + +- **`#189`** — Atelet OCI bundle gaps (`Args`, `Secret`, symlinks). +- **`#197` Bug 2a** — `valueFrom.secretKeyRef` on `ActorTemplate` container env is not supported today. `GEMINI_API_KEY` is passed as a plain `value:` env var (envsubst-substituted at apply time) until upstream support lands. +- **`#197` Bug 3** — Atelet symlink resolution. + +## Teardown + +```bash +./hack/install-ate.sh --delete-demo-gemini-cli-multiplex +``` + +This removes the `gemini-cli-multiplex-demo` namespace and all the resources created by the deploy step. You can also stop the port-forward and the dashboard processes in their respective terminals. diff --git a/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl b/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl new file mode 100644 index 000000000..715c743ff --- /dev/null +++ b/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl @@ -0,0 +1,154 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Three ActorTemplates share a 2-pod WorkerPool, so substrate must suspend +# at least one actor at any moment. GEMINI_API_KEY is passed as a plain +# env var per dberkov's PR #203 review — substrate does not currently +# support `valueFrom.secretKeyRef` on ActorTemplate container env. +# +# WORKLOAD_IMAGE is the resolved sha256-digest reference for the +# gemini-cli-multiplex-demo-workload image — built and pushed to +# ${KO_DOCKER_REPO} by `./hack/install-ate.sh --deploy-demo-gemini-cli-multiplex`, +# substituted into this template at apply time. + +apiVersion: v1 +kind: Namespace +metadata: + name: gemini-cli-multiplex-demo + +--- + +# 2 worker replicas for 3 actors — the multiplex pressure that makes the +# substrate suspend/resume behavior visible. +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: gemini-workerpool + namespace: gemini-cli-multiplex-demo +spec: + replicas: 2 + ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + +--- + +# Three ActorTemplates, each running the same workload image with a +# distinct TASK prompt. Each template spawns one named actor, so there +# are 3 actors competing for 2 worker pods → substrate must suspend +# at least one at any moment. + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: agent-luna + namespace: gemini-cli-multiplex-demo +spec: + runsc: + amd64: + url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc" + sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63" + arm64: + url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc" + sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9" + pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + containers: + - name: gemini + image: ${WORKLOAD_IMAGE} + env: + - name: ACTOR_NAME + value: "luna" + - name: TASK + value: "Tell me one short, surprising fact about the Moon. One sentence." + - name: INTERVAL_SECONDS + value: "45" + - name: GEMINI_MODEL + value: "gemini-2.5-flash" + - name: GEMINI_API_KEY + value: "${GEMINI_API_KEY}" + workerPoolRef: + namespace: gemini-cli-multiplex-demo + name: gemini-workerpool + snapshotsConfig: + location: gs://${BUCKET_NAME}/gemini-cli-multiplex-demo/ + +--- + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: agent-mars + namespace: gemini-cli-multiplex-demo +spec: + runsc: + amd64: + url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc" + sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63" + arm64: + url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc" + sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9" + pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + containers: + - name: gemini + image: ${WORKLOAD_IMAGE} + env: + - name: ACTOR_NAME + value: "mars" + - name: TASK + value: "Give me one concise tip for learning a new programming language. One sentence." + - name: INTERVAL_SECONDS + value: "45" + - name: GEMINI_MODEL + value: "gemini-2.5-flash" + - name: GEMINI_API_KEY + value: "${GEMINI_API_KEY}" + workerPoolRef: + namespace: gemini-cli-multiplex-demo + name: gemini-workerpool + snapshotsConfig: + location: gs://${BUCKET_NAME}/gemini-cli-multiplex-demo/ + +--- + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: agent-orion + namespace: gemini-cli-multiplex-demo +spec: + runsc: + amd64: + url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc" + sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63" + arm64: + url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc" + sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9" + pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + containers: + - name: gemini + image: ${WORKLOAD_IMAGE} + env: + - name: ACTOR_NAME + value: "orion" + - name: TASK + value: "Suggest one healthy meal that takes under 15 minutes to prepare. One sentence." + - name: INTERVAL_SECONDS + value: "45" + - name: GEMINI_MODEL + value: "gemini-2.5-flash" + - name: GEMINI_API_KEY + value: "${GEMINI_API_KEY}" + workerPoolRef: + namespace: gemini-cli-multiplex-demo + name: gemini-workerpool + snapshotsConfig: + location: gs://${BUCKET_NAME}/gemini-cli-multiplex-demo/ diff --git a/demos/gemini-cli-multiplex/ui/index.html b/demos/gemini-cli-multiplex/ui/index.html new file mode 100644 index 000000000..72bc4f845 --- /dev/null +++ b/demos/gemini-cli-multiplex/ui/index.html @@ -0,0 +1,424 @@ + + + + + + +Gemini CLI multiplex demo + + + +
+

Gemini CLI multiplex demo

+
connecting…
+
+ +

+ This demo runs 3 Gemini CLI agents on + 2 substrate worker pods. Each agent has its own short prompt + and works in a loop: ask Gemini, print the answer, idle for 45 seconds, + repeat. While an agent is idle, substrate can suspend it + (snapshot its process state, free its pod) and let a different agent borrow + that pod — that’s the multiplex. The dashboard + refreshes every 2.5 seconds; pods, actors, and logs are all live so you + can watch the rotation happen. +

+ +
+ Approximate cost while running + GCP infrastructure: ~$0.40/hr + Gemini API (3 agents): <$0.50/hr when active + Total: ~$0.90/hr typical + + GCP figure is one n2-standard-8 VM in us-central1 (ephemeral IP free). Gemini figure + assumes gemini-2.5-flash at published rates, each agent firing one short prompt every 45 s, + multiplexed across 2 pods (so about 2 of the 3 agents are actively running at any moment). + During the current substrate boundary the agents aren’t invoking Gemini, so the real + spend is just the VM — that flips up once the substrate chain resolves. + +
+ +
+
+

Tasks

+ + +
+

+ Click Give a task to assign a randomly chosen short task to a + randomly chosen agent. Each task moves through three states — + queued + while the agent is suspended, + running + while it owns a worker pod, and + completed + once substrate suspends it again. +

+
+
no tasks yet — click Give a task to start
+
+
+ +
+
+

Worker pods

+

+ The pool of substrate-managed pods that actually host running agents. The + WorkerPool is configured with 2 replicas for + 3 agents, so substrate is forced to share — at any + moment at most 2 agents own pods, and the third is suspended waiting its + turn. Substrate rotates ownership as agents transition between active and + idle phases. +

+
loading…
+
+
+

Actors & templates

+

+ ActorTemplates define each agent (container image, + prompt, idle interval). Actors are the live instances + bound — or not — to a worker pod. Watch the + phase: Running means the actor is on a pod + executing right now; Suspended means its state is stored and + it’s waiting for a free pod; Resuming / + ResumeGoldenActor are the substrate transitions in between. +

+
loading…
+
+
+ +

Live logs (per pod, last 25 lines, all containers)

+

+ Each card below tails the logs of one worker pod. Because substrate moves + agents between pods, the log stream you see in a single card switches + ownership over time — you’ll see one agent’s + [demo-actor: agent-luna] tick output, then a transition, then + [demo-actor: agent-mars] picking up on the same pod. That + ownership transition is the multiplex in action. +

+
+ + + + diff --git a/demos/gemini-cli-multiplex/ui/server.go b/demos/gemini-cli-multiplex/ui/server.go new file mode 100644 index 000000000..1dea36d87 --- /dev/null +++ b/demos/gemini-cli-multiplex/ui/server.go @@ -0,0 +1,540 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Demo UI server — substrate multiplex visualization (Gemini CLI flavor). +// +// Tiny stdlib HTTP server. Reads worker + actor state from the substrate +// ateapi gRPC service, reads pod logs from the k8s API via client-go, +// exposes JSON endpoints, and serves index.html that polls them. +// +// No kubectl shellouts — all data flows go through: +// 1. ateapi gRPC (workers / actors / actor names) — mirrors the pattern +// in demos/sandbox/client/main.go. +// 2. client-go corev1 typed client (pod logs) — uses the operator's +// kubeconfig when running outside the cluster; in-cluster service +// account credentials otherwise. +// +// Prereq when running outside the cluster: +// +// kubectl port-forward svc/ateapi 8080:8080 -n ate-system & +// PORT=8090 ATEAPI_ADDR=localhost:8080 DEMO_NAMESPACE=gemini-cli-multiplex-demo go run ./server.go +// +// (Pick a UI PORT that doesn't collide with the port-forward.) +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + defaultPort = "8080" + defaultNamespace = "gemini-cli-multiplex-demo" + defaultAteapiAddr = "localhost:8080" + maxAssignments = 50 + rpcTimeout = 10 * time.Second + logTailLines = int64(25) + + // Assignment lifecycle states the UI badge logic reads. + // queued → running → completed; computeState drives the + // transitions from elapsed-since-creation. + stateQueued = "queued" + stateRunning = "running" + stateCompleted = "completed" +) + +var predefinedTasks = []string{ + "Review main.py and suggest two improvements", + "Explain how a Kubernetes ReplicaSet differs from a Deployment", + "Write a Python function to detect duplicate items in a list", + "Summarize the difference between a Pod and a Job in Kubernetes", + "List three best practices for writing testable Python code", + "Draft a one-paragraph summary of Python garbage collection", + "Suggest two ways to make a Kubernetes Service more resilient", + "Write a unit test for a function that returns the max of two integers", + "Explain the role of an admission controller in Kubernetes", + "Outline a backoff strategy for a flaky HTTP client", +} + +type assignment struct { + ID string `json:"id"` + Agent string `json:"agent"` + Task string `json:"task"` + State string `json:"state"` + CreatedAt float64 `json:"created_at"` + StartedAt *float64 `json:"started_at"` + CompletedAt *float64 `json:"completed_at"` + QueueFor float64 `json:"queue_for"` + RunFor float64 `json:"run_for"` +} + +// podSummary is the per-worker shape the UI's index.html renders. Field +// names match the original kubectl-shellout JSON contract so the page +// doesn't need to change. Some k8s-specific fields (Node, StartedAt, +// Image) aren't surfaced by ateapi today; we backfill with substrate- +// native semantics: Node ← worker_pool, StartedAt ← "" (omit), and the +// Image field is dropped since the UI doesn't read it. +type podSummary struct { + Name string `json:"name"` + Node string `json:"node"` + Phase string `json:"phase"` + Ready bool `json:"ready"` + StartedAt string `json:"started_at"` +} + +// actorSummary mirrors the original kubectl-plugin JSON contract. Kind +// is always "Actor" now (ActorTemplates / WorkerPools are not surfaced +// via ateapi today); Phase is derived from the proto Status enum. +type actorSummary struct { + Kind string `json:"kind"` + Name string `json:"name"` + Phase string `json:"phase"` + Message string `json:"message"` +} + +var ( + namespace = envOr("DEMO_NAMESPACE", defaultNamespace) + ateapiAddr = envOr("ATEAPI_ADDR", defaultAteapiAddr) + rootDir = mustRootDir() + mu sync.Mutex + assignments = make([]*assignment, 0, maxAssignments) // newest first + + ateClient ateapipb.ControlClient + ateConn *grpc.ClientConn + kubeClient *kubernetes.Clientset +) + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// mustRootDir returns the directory holding this server's index.html. +// Use os.Executable when available (covers `go build` + run); fall +// back to the current working directory (covers `go run` where the +// executable is in /tmp). +func mustRootDir() string { + if exe, err := os.Executable(); err == nil { + if d := filepath.Dir(exe); fileExists(filepath.Join(d, "index.html")) { + return d + } + } + if wd, err := os.Getwd(); err == nil { + return wd + } + return "." +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// dialAteAPI opens a gRPC client to the substrate ateapi service. +// Mirrors demos/sandbox/client/main.go: TLS with InsecureSkipVerify +// (ateapi serves a self-signed cert; the demo trusts whichever +// instance the port-forward / in-cluster DNS resolves to). +func dialAteAPI(endpoint string) (ateapipb.ControlClient, *grpc.ClientConn, error) { + creds := credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}) + conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, nil, err + } + return ateapipb.NewControlClient(conn), conn, nil +} + +// newKubeClient returns a typed kubernetes client. Tries in-cluster +// config first (works when running as a pod); falls back to the +// operator's kubeconfig (works when running on a dev VM after +// `gcloud container clusters get-credentials` / `kind export +// kubeconfig`). Returns nil + nil error when neither is available +// — log endpoints will then 503 gracefully without crashing the +// server (handy when iterating on the demo locally with no +// cluster context). +func newKubeClient() (*kubernetes.Clientset, error) { + if cfg, err := rest.InClusterConfig(); err == nil { + return kubernetes.NewForConfig(cfg) + } + + loader := clientcmd.NewDefaultClientConfigLoadingRules() + cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loader, &clientcmd.ConfigOverrides{}).ClientConfig() + if err != nil { + // No usable kube config — return nil clientset, no error; + // handleLogs will degrade to a clear 503. + log.Printf("[ui] no kube context available (logs disabled): %v", err) + return nil, nil + } + return kubernetes.NewForConfig(cfg) +} + +// actorStatusString maps the proto Status enum to the human-readable +// phase string the UI's badge logic understands (running / suspended +// / etc). +func actorStatusString(s ateapipb.Actor_Status) string { + switch s { + case ateapipb.Actor_STATUS_RESUMING: + return "Resuming" + case ateapipb.Actor_STATUS_RUNNING: + return "Running" + case ateapipb.Actor_STATUS_SUSPENDING: + return "Suspending" + case ateapipb.Actor_STATUS_SUSPENDED: + return "Suspended" + default: + return "?" + } +} + +// workerPhase derives a pod-like phase string from a substrate Worker. +// A worker hosting an actor is "Running"; an idle worker is "Idle". +// The UI's badgeFor() treats "running" as green; "idle" falls through +// to the neutral badge, which is the right visual treatment. +func workerPhase(w *ateapipb.Worker) string { + if w.GetActorId() != "" { + return "Running" + } + return "Idle" +} + +// listActorNames returns current actor IDs in the namespace via +// ateapi. Replaces the prior kubectl-shellout fallback chain. +func listActorNames(ctx context.Context) []string { + if ateClient == nil { + return nil + } + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := ateClient.ListActors(rctx, &ateapipb.ListActorsRequest{}) + if err != nil { + log.Printf("[ui] ListActors error: %v", err) + return nil + } + names := make([]string, 0, len(resp.GetActors())) + for _, a := range resp.GetActors() { + if id := a.GetActorId(); id != "" { + names = append(names, id) + } + } + sort.Strings(names) + return names +} + +// computeState returns the timer-driven UI state for an assignment. +// queued → running → completed (purely client-time-driven; the +// substrate side has no concept of these per-task states). +func computeState(asg *assignment) string { + elapsed := nowSec() - asg.CreatedAt + if elapsed < asg.QueueFor { + return stateQueued + } + if elapsed < asg.QueueFor+asg.RunFor { + return stateRunning + } + return stateCompleted +} + +// applyComputedStates walks current assignments and stamps started_at / +// completed_at as states advance. Caller must NOT hold mu. +func applyComputedStates() { + mu.Lock() + defer mu.Unlock() + for _, asg := range assignments { + newState := computeState(asg) + if newState == asg.State { + continue + } + asg.State = newState + if newState == stateRunning && asg.StartedAt == nil { + v := asg.CreatedAt + asg.QueueFor + asg.StartedAt = &v + } else if newState == stateCompleted && asg.CompletedAt == nil { + v := asg.CreatedAt + asg.QueueFor + asg.RunFor + asg.CompletedAt = &v + } + } +} + +func nowSec() float64 { + return float64(time.Now().UnixNano()) / 1e9 +} + +// giveTask picks a random task + random agent and records the assignment. +// Returns nil-string error if no agents. +func giveTask(ctx context.Context) (*assignment, string) { + agents := listActorNames(ctx) + if len(agents) == 0 { + return nil, "no agents available in namespace" + } + now := nowSec() + asg := &assignment{ + ID: fmt.Sprintf("asg-%d", time.Now().UnixMilli()), + Agent: agents[rand.Intn(len(agents))], + Task: predefinedTasks[rand.Intn(len(predefinedTasks))], + State: stateQueued, + CreatedAt: now, + QueueFor: roundOne(2.0 + rand.Float64()*3.0), // 2.0–5.0 + RunFor: roundOne(9.0 + rand.Float64()*7.0), // 9.0–16.0 + } + mu.Lock() + defer mu.Unlock() + assignments = append([]*assignment{asg}, assignments...) + if len(assignments) > maxAssignments { + assignments = assignments[:maxAssignments] + } + return asg, "" +} + +func roundOne(v float64) float64 { + return float64(int(v*10+0.5)) / 10 +} + +// writeJSON serializes body as JSON with no-store cache headers. +func writeJSON(w http.ResponseWriter, status int, body interface{}) { + data, err := json.Marshal(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _, _ = w.Write(data) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + f, err := os.Open(filepath.Join(rootDir, "index.html")) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.Copy(w, f) +} + +func handleHealthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "namespace": namespace, + "ateapi_addr": ateapiAddr, + "logs": kubeClient != nil, + }) +} + +// handlePods returns worker-shaped JSON sourced from ateapi.ListWorkers. +// The JSON shape mirrors the original kubectl-shellout contract so +// index.html doesn't need to change. +func handlePods(w http.ResponseWriter, r *http.Request) { + if ateClient == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "ateapi client not initialized"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), rpcTimeout) + defer cancel() + resp, err := ateClient.ListWorkers(ctx, &ateapipb.ListWorkersRequest{}) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "ListWorkers: " + err.Error()}) + return + } + pods := make([]podSummary, 0, len(resp.GetWorkers())) + for _, wk := range resp.GetWorkers() { + // Filter to the demo namespace when set — workers may live + // in their own pool namespace (worker_namespace) so we + // compare against actor_namespace too. + if namespace != "" && wk.GetActorNamespace() != "" && wk.GetActorNamespace() != namespace { + continue + } + pods = append(pods, podSummary{ + Name: wk.GetWorkerPod(), + Node: wk.GetWorkerPool(), // closest semantic analog + Phase: workerPhase(wk), + Ready: wk.GetActorId() != "", + StartedAt: "", // not exposed by ateapi today + }) + } + sort.Slice(pods, func(i, j int) bool { return pods[i].Name < pods[j].Name }) + writeJSON(w, http.StatusOK, map[string][]podSummary{"pods": pods}) +} + +// handleActors returns actor-shaped JSON sourced from ateapi.ListActors. +// ActorTemplates / WorkerPools (k8s CRDs) are no longer surfaced — +// substrate-native Actors are the canonical demo entity. +func handleActors(w http.ResponseWriter, r *http.Request) { + if ateClient == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "ateapi client not initialized"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), rpcTimeout) + defer cancel() + resp, err := ateClient.ListActors(ctx, &ateapipb.ListActorsRequest{}) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "ListActors: " + err.Error()}) + return + } + actors := make([]actorSummary, 0, len(resp.GetActors())) + for _, a := range resp.GetActors() { + if namespace != "" && a.GetActorTemplateNamespace() != "" && a.GetActorTemplateNamespace() != namespace { + continue + } + // Carry the template name as the meta message so the UI's + // secondary line shows useful provenance (the original + // kubectl path put k8s `status.message` here — for + // substrate Actors there's no equivalent, so the template + // name is the closest semantic match). + msg := "" + if t := a.GetActorTemplateName(); t != "" { + msg = "template: " + t + } + actors = append(actors, actorSummary{ + Kind: "Actor", + Name: a.GetActorId(), + Phase: actorStatusString(a.GetStatus()), + Message: msg, + }) + } + sort.Slice(actors, func(i, j int) bool { return actors[i].Name < actors[j].Name }) + writeJSON(w, http.StatusOK, map[string][]actorSummary{"actors": actors}) +} + +// handleLogs streams the last N lines of a pod's logs via the typed +// k8s client. Replaces the prior `kubectl logs --tail=25` shellout. +func handleLogs(w http.ResponseWriter, r *http.Request) { + pod := strings.TrimPrefix(r.URL.Path, "/api/logs/") + pod = strings.Trim(pod, "/") + if pod == "" || strings.Contains(pod, "/") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad pod ref"}) + return + } + if kubeClient == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "k8s client not initialized"}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), rpcTimeout) + defer cancel() + tail := logTailLines + opts := &corev1.PodLogOptions{TailLines: &tail} + req := kubeClient.CoreV1().Pods(namespace).GetLogs(pod, opts) + stream, err := req.Stream(ctx) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + defer stream.Close() + data, err := io.ReadAll(stream) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error(), "logs": string(data)}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"logs": string(data), "stderr": ""}) +} + +func handleTaskStatus(w http.ResponseWriter, _ *http.Request) { + applyComputedStates() + mu.Lock() + snapshot := make([]*assignment, len(assignments)) + copy(snapshot, assignments) + mu.Unlock() + writeJSON(w, http.StatusOK, map[string][]*assignment{"assignments": snapshot}) +} + +func handleGiveTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + // Drain request body if any (we don't actually need it; click semantics only). + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + asg, errMsg := giveTask(r.Context()) + if errMsg != "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": errMsg}) + return + } + writeJSON(w, http.StatusOK, asg) +} + +func main() { + port := envOr("PORT", defaultPort) + + // Open the ateapi connection up front so the UI surfaces a clear + // startup error if the operator forgot the port-forward, rather + // than per-request failures with cryptic gRPC messages. + log.Printf("[ui] dialing ateapi at %s", ateapiAddr) + cli, conn, err := dialAteAPI(ateapiAddr) + if err != nil { + log.Fatalf("dial ateapi: %v", err) + } + ateClient = cli + ateConn = conn + defer ateConn.Close() + + // Best-effort k8s client for logs; nil is OK (handleLogs degrades + // to a 503 with a clear message). This lets the demo start even + // when no cluster context is configured — useful for quick UI + // shape iteration. + kc, kerr := newKubeClient() + if kerr != nil { + log.Printf("[ui] kube client init error (logs disabled): %v", kerr) + } + kubeClient = kc + + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/healthz", handleHealthz) + mux.HandleFunc("/api/pods", handlePods) + mux.HandleFunc("/api/actors", handleActors) + mux.HandleFunc("/api/logs/", handleLogs) + mux.HandleFunc("/api/task-status", handleTaskStatus) + mux.HandleFunc("/api/give-task", handleGiveTask) + + addr := "0.0.0.0:" + port + log.Printf("[ui] serving %s (namespace=%s ateapi=%s logs=%t)", addr, namespace, ateapiAddr, kubeClient != nil) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } +} diff --git a/demos/gemini-cli-multiplex/workload/Dockerfile b/demos/gemini-cli-multiplex/workload/Dockerfile new file mode 100644 index 000000000..98a388fff --- /dev/null +++ b/demos/gemini-cli-multiplex/workload/Dockerfile @@ -0,0 +1,30 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM node:20-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @google/gemini-cli@latest + +COPY run.sh /run.sh +RUN chmod +x /run.sh + +ENV TASK="Tell me a fact about the moon." +ENV INTERVAL_SECONDS=60 +ENV GEMINI_MODEL=gemini-2.5-flash + +ENTRYPOINT ["/run.sh"] diff --git a/demos/gemini-cli-multiplex/workload/run.sh b/demos/gemini-cli-multiplex/workload/run.sh new file mode 100644 index 000000000..02a5c563c --- /dev/null +++ b/demos/gemini-cli-multiplex/workload/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Demo workload entrypoint: periodically invokes Gemini CLI with a task and +# idles between intervals. The idle window is what substrate uses to suspend +# this actor and multiplex its worker onto another actor. +# +# Env vars: +# TASK — the prompt to pass to gemini each tick +# INTERVAL_SECONDS — sleep length between ticks (longer = more multiplex headroom) +# GEMINI_MODEL — model id (default gemini-2.5-flash) +# GEMINI_API_KEY — required; AI Studio key, supplied as env var + +set -u + +if [ -z "${GEMINI_API_KEY:-}" ]; then + echo "[demo-actor] ERROR: GEMINI_API_KEY not set; refusing to start" >&2 + exit 1 +fi + +ACTOR_NAME="${ACTOR_NAME:-$(hostname)}" +TICK=0 + +echo "[demo-actor:${ACTOR_NAME}] starting; task=\"${TASK}\" interval=${INTERVAL_SECONDS}s model=${GEMINI_MODEL}" + +while true; do + TICK=$((TICK + 1)) + echo "" + echo "[demo-actor:${ACTOR_NAME}] === tick ${TICK} at $(date -u +%H:%M:%SZ) ===" + echo "[demo-actor:${ACTOR_NAME}] running: ${TASK}" + echo "---" + # -p runs one prompt non-interactively and exits; output streams to stdout + # so kubectl logs picks it up live. + gemini -m "${GEMINI_MODEL}" -p "${TASK}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] gemini exited non-zero" + echo "---" + echo "[demo-actor:${ACTOR_NAME}] tick ${TICK} done; sleeping ${INTERVAL_SECONDS}s" + sleep "${INTERVAL_SECONDS}" +done diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 6c7f46056..a30f7b4d9 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -42,6 +42,7 @@ ATE_DEMOS=() source "${ROOT}"/hack/install-demo-counter.sh source "${ROOT}"/hack/install-demo-sandbox.sh source "${ROOT}"/hack/install-demo-claude-code-multiplex.sh +source "${ROOT}"/hack/install-demo-gemini-cli-multiplex.sh source "${ROOT}"/hack/install-demo-agent-secret.sh source "${ROOT}"/hack/install-demo-multi-template.sh diff --git a/hack/install-demo-gemini-cli-multiplex.sh b/hack/install-demo-gemini-cli-multiplex.sh new file mode 100644 index 000000000..1734cbc33 --- /dev/null +++ b/hack/install-demo-gemini-cli-multiplex.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This is sourced as part of install-ate.sh. Do not run directly. + +ATE_DEMOS+=(demo-gemini-cli-multiplex) # register demo-gemini-cli-multiplex + +demo-gemini-cli-multiplex_cmdline() { + case "${1}" in + --deploy-demo-gemini-cli-multiplex) demo-gemini-cli-multiplex_deploy ;; + --delete-demo-gemini-cli-multiplex) demo-gemini-cli-multiplex_delete ;; + *) + return 1 + ;; + esac + return 0 +} + +# Build the workload image, push to ${KO_DOCKER_REPO}, and echo the resolved +# digest-pinned reference (e.g. gcr.io/.../gemini-cli-multiplex-demo-workload@sha256:...). +# The workload is a Dockerfile-based Node + @google/gemini-cli wrapper (not a Go +# binary), so it uses docker buildx rather than ko. +demo-gemini-cli-multiplex_build_workload() { + local repo="${KO_DOCKER_REPO}/gemini-cli-multiplex-demo-workload" + # shellcheck disable=SC2155 # safe initialization + local stage_tag="${repo}:build-$(date +%s)" + docker buildx build \ + --platform=linux/amd64 \ + --push \ + -t "${stage_tag}" \ + demos/gemini-cli-multiplex/workload >&2 + local digest + digest=$(docker buildx imagetools inspect "${stage_tag}" --format '{{json .}}' \ + | jq -r '.manifest.digest') + if [[ -z "${digest}" || "${digest}" == "null" ]]; then + echo "Failed to resolve workload image digest from ${stage_tag}" >&2 + return 1 + fi + echo "${repo}@${digest}" +} + +demo-gemini-cli-multiplex_deploy() { + log_step "demo-gemini-cli-multiplex_deploy" + if [[ -z "${GEMINI_API_KEY:-}" ]]; then + echo "GEMINI_API_KEY must be set" >&2 + return 1 + fi + if [[ -z "${BUCKET_NAME:-}" ]]; then + echo "BUCKET_NAME must be set" >&2 + return 1 + fi + if [[ -z "${KO_DOCKER_REPO:-}" ]]; then + echo "KO_DOCKER_REPO must be set (see hack/ate-dev-env.sh.example)" >&2 + return 1 + fi + + local workload_image + workload_image=$(demo-gemini-cli-multiplex_build_workload) + if [[ -z "${workload_image}" ]]; then + return 1 + fi + log_step " workload image: ${workload_image}" + + sed -e "s|\${BUCKET_NAME}|${BUCKET_NAME}|g" \ + -e "s|\${GEMINI_API_KEY}|${GEMINI_API_KEY}|g" \ + -e "s|\${WORKLOAD_IMAGE}|${workload_image}|g" \ + demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl \ + | run_ko apply -f - +} + +demo-gemini-cli-multiplex_delete() { + log_step "demo-gemini-cli-multiplex_delete" + # Delete-time substitution doesn't need a real image — k8s identifies + # resources by metadata, not container spec. Use placeholders so sed + # produces valid YAML even when the env vars aren't set. + sed -e "s|\${BUCKET_NAME}|${BUCKET_NAME:-placeholder}|g" \ + -e "s|\${GEMINI_API_KEY}|${GEMINI_API_KEY:-placeholder}|g" \ + -e "s|\${WORKLOAD_IMAGE}|placeholder|g" \ + demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl \ + | run_kubectl delete --ignore-not-found -f - +} + +demo-gemini-cli-multiplex_usage() { + echo "" + echo " Required env: GEMINI_API_KEY, BUCKET_NAME, KO_DOCKER_REPO" + echo " See demos/gemini-cli-multiplex/README.md for the walkthrough." +} From e94ec14f5a5b0c3fc1c877b5dd3e3e7779dfc658 Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Sun, 7 Jun 2026 18:41:12 -0400 Subject: [PATCH 2/3] tmp --- demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl b/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl index 715c743ff..2f9bbdf29 100644 --- a/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl +++ b/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl @@ -64,6 +64,7 @@ spec: containers: - name: gemini image: ${WORKLOAD_IMAGE} + command: ["/run.sh"] env: - name: ACTOR_NAME value: "luna" @@ -100,6 +101,7 @@ spec: containers: - name: gemini image: ${WORKLOAD_IMAGE} + command: ["/run.sh"] env: - name: ACTOR_NAME value: "mars" @@ -136,6 +138,7 @@ spec: containers: - name: gemini image: ${WORKLOAD_IMAGE} + command: ["/run.sh"] env: - name: ACTOR_NAME value: "orion" From f5139dcf4e800dc3d90ff20d764f9c374cffde2a Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Wed, 10 Jun 2026 17:06:16 -0400 Subject: [PATCH 3/3] gemini to antigravity Signed-off-by: adminturneddevops --- .../README.md | 43 +++++++------ .../antigravity-cli-multiplex.yaml.tmpl} | 62 +++++++++---------- .../ui/index.html | 16 ++--- .../ui/server.go | 6 +- .../workload/Dockerfile | 21 +++++-- .../workload/run.sh | 22 ++++--- hack/install-ate.sh | 2 +- ...install-demo-antigravity-cli-multiplex.sh} | 46 ++++++-------- 8 files changed, 109 insertions(+), 109 deletions(-) rename demos/{gemini-cli-multiplex => antigravity-cli-multiplex}/README.md (55%) rename demos/{gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl => antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl} (75%) rename demos/{gemini-cli-multiplex => antigravity-cli-multiplex}/ui/index.html (97%) rename demos/{gemini-cli-multiplex => antigravity-cli-multiplex}/ui/server.go (98%) rename demos/{gemini-cli-multiplex => antigravity-cli-multiplex}/workload/Dockerfile (52%) rename demos/{gemini-cli-multiplex => antigravity-cli-multiplex}/workload/run.sh (64%) rename hack/{install-demo-gemini-cli-multiplex.sh => install-demo-antigravity-cli-multiplex.sh} (60%) diff --git a/demos/gemini-cli-multiplex/README.md b/demos/antigravity-cli-multiplex/README.md similarity index 55% rename from demos/gemini-cli-multiplex/README.md rename to demos/antigravity-cli-multiplex/README.md index 252bdc852..66a838a0b 100644 --- a/demos/gemini-cli-multiplex/README.md +++ b/demos/antigravity-cli-multiplex/README.md @@ -1,13 +1,13 @@ -# Gemini CLI Multiplex Demo +# Antigravity CLI Multiplex Demo -A demo of three Gemini-CLI-driven agents sharing two Agent Substrate pods. Substrate suspends idle agents and resumes them on demand, so the cluster runs *fewer pods than agents*. +A demo of three Antigravity-driven agents sharing two Agent Substrate pods. Substrate suspends idle agents and resumes them on demand, so the cluster runs *fewer pods than agents*. > [!NOTE] > This demo intentionally provisions **two pods for three agents** to exercise substrate's suspend/resume path. The same pattern scales — ten agents on three pods, a hundred agents on twenty. ## What this shows -- Three Gemini CLI agents (`luna`, `mars`, `orion`) registered as Substrate actors. +- Three Antigravity agents (`luna`, `mars`, `orion`) registered as Substrate actors. - A `WorkerPool` of two pods. - A small web UI that drives "give a task" against random idle agents and renders the queued/running/completed badge state per agent. - Substrate handles the hard parts: state snapshot on suspend, scheduling decisions, resume-correctness when a pod becomes available. @@ -25,41 +25,40 @@ This guide assumes you know Kubernetes and the general shape of agent runtimes ( # Terminal 1: ateapi port-forward kubectl port-forward svc/ateapi 8080:8080 -n ate-system ``` -- A **Gemini API key** from Google AI Studio (the agents call Gemini via `@google/gemini-cli`). +- Antigravity authentication configured for the command you run in the workload. The official Linux package is installed from Google's Antigravity apt repo. - A GCS bucket for substrate state snapshots (configured during Substrate install). - `KO_DOCKER_REPO` set to a registry you can push to (e.g. `gcr.io/${PROJECT_ID}/ate-images`, same as `hack/ate-dev-env.sh.example`). The deploy step builds and pushes the workload image there with a sha256-pinned reference. -- `docker buildx` (the deploy function builds the workload image — a Dockerfile-based Node + `@google/gemini-cli` wrapper, not a Go binary, so `ko` doesn't apply for the workload itself). +- `docker buildx` and `jq` (the deploy function builds the workload image — a Dockerfile-based Antigravity wrapper, not a Go binary, so `ko` doesn't apply for the workload itself). ## Components | Path | Purpose | |---|---| -| `demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl` | Namespace, WorkerPool, ActorTemplates in a single envsubst template | -| `hack/install-demo-gemini-cli-multiplex.sh` | Sourced by `install-ate.sh`; registers `--deploy-demo-gemini-cli-multiplex` and `--delete-demo-gemini-cli-multiplex` | -| `demos/gemini-cli-multiplex/workload/` | The agent container image source (Dockerfile + entrypoint that wires Gemini CLI; built and pushed by the deploy step) | -| `demos/gemini-cli-multiplex/ui/` | Static dashboard (`index.html` + `server.go`) that talks to the cluster | +| `demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl` | Namespace, WorkerPool, ActorTemplates in a single envsubst template | +| `hack/install-demo-antigravity-cli-multiplex.sh` | Sourced by `install-ate.sh`; registers `--deploy-demo-antigravity-cli-multiplex` and `--delete-demo-antigravity-cli-multiplex` | +| `demos/antigravity-cli-multiplex/workload/` | The agent container image source (Dockerfile + entrypoint that wires Antigravity; built and pushed by the deploy step) | +| `demos/antigravity-cli-multiplex/ui/` | Static dashboard (`index.html` + `server.go`) that talks to the cluster | ## How to Run ### 1. Deploy the demo -From the repo root, with your Gemini key and substrate bucket name in the environment: +From the repo root, with your substrate bucket name in the environment: ```bash -GEMINI_API_KEY=... \ BUCKET_NAME=your-substrate-bucket \ - ./hack/install-ate.sh --deploy-demo-gemini-cli-multiplex + ./hack/install-ate.sh --deploy-demo-antigravity-cli-multiplex ``` -This creates the `gemini-cli-multiplex-demo` namespace, a 2-pod `WorkerPool`, and three `ActorTemplate` objects named `luna`, `mars`, `orion`. Under the hood, the deploy function builds the workload image with `docker buildx`, pushes it to `${KO_DOCKER_REPO}/gemini-cli-multiplex-demo-workload`, resolves the pushed sha256 digest, and substitutes the digest-pinned reference plus `GEMINI_API_KEY` and `BUCKET_NAME` into the manifest template at apply time. +This creates the `antigravity-cli-multiplex-demo` namespace, a 2-pod `WorkerPool`, and three `ActorTemplate` objects named `luna`, `mars`, `orion`. Under the hood, the deploy function builds the workload image with `docker buildx`, pushes it to `${KO_DOCKER_REPO}/antigravity-cli-multiplex-demo-workload`, resolves the pushed sha256 digest, and substitutes the digest-pinned reference plus `BUCKET_NAME` into the manifest template at apply time. -The model is `gemini-2.5-flash` by default; override per-template by editing the `GEMINI_MODEL` env in `gemini-cli-multiplex.yaml.tmpl` before deploying. +The workload executes `ANTIGRAVITY_COMMAND` once per tick. The default template command is intentionally explicit and easy to update because the public Antigravity package currently exposes the official `antigravity` binary, but does not document a single-prompt headless flag in the public docs. Override per-template by editing `ANTIGRAVITY_COMMAND` in `antigravity-cli-multiplex.yaml.tmpl` before deploying. Check that everything is running as expected: ```bash # k8s-native resources (these work with plain kubectl) -kubectl get pods,workerpool,actortemplate -n gemini-cli-multiplex-demo +kubectl get pods,workerpool,actortemplate -n antigravity-cli-multiplex-demo # Substrate-native (uses the kubectl-ate plugin against ateapi) kubectl ate get actors @@ -71,14 +70,14 @@ kubectl ate get workers Make sure the ateapi port-forward from the [Prerequisites](#prerequisites) is still running, then: ```bash -cd demos/gemini-cli-multiplex/ui +cd demos/antigravity-cli-multiplex/ui PORT=8090 ATEAPI_ADDR=localhost:8080 go run . ``` Or build a binary: ```bash -cd demos/gemini-cli-multiplex/ui +cd demos/antigravity-cli-multiplex/ui go build -o ui-server . PORT=8090 ATEAPI_ADDR=localhost:8080 ./ui-server ``` @@ -91,7 +90,7 @@ Env vars: |---|---|---| | `PORT` | `8080` | TCP port the dashboard binds (pick `≠ ATEAPI_ADDR`'s port when both run on the same host). | | `ATEAPI_ADDR` | `localhost:8080` | Address of the substrate ateapi gRPC service. | -| `DEMO_NAMESPACE` | `gemini-cli-multiplex-demo` | Kubernetes namespace the dashboard filters to and reads pod logs from. | +| `DEMO_NAMESPACE` | `antigravity-cli-multiplex-demo` | Kubernetes namespace the dashboard filters to and reads pod logs from. | `GET /healthz` reports whether the kube client picked up a cluster context (`logs:true|false`) — useful for quick smoke-tests after starting the server. @@ -101,7 +100,7 @@ Click "Give a task". The UI picks a random idle agent and creates a task for it. - Badge flips to `queued` (the agent has work but isn't bound to a pod yet). - Substrate finds a free pod and binds the agent. Badge flips to `running`. -- The agent calls Gemini, writes a result, exits. Badge flips to `completed`. +- The agent runs the configured Antigravity command, writes a result, exits. Badge flips to `completed`. - Substrate notices the inactivity and suspends the agent after a short idle window. - The released pod becomes available for the next queued task on a different agent. @@ -112,13 +111,13 @@ With three agents and two pods, the third agent stays suspended (state snapshott Same upstream Substrate issues as the Claude Code variant. Each will be addressed by a separate upstream fix PR. - **`#189`** — Atelet OCI bundle gaps (`Args`, `Secret`, symlinks). -- **`#197` Bug 2a** — `valueFrom.secretKeyRef` on `ActorTemplate` container env is not supported today. `GEMINI_API_KEY` is passed as a plain `value:` env var (envsubst-substituted at apply time) until upstream support lands. +- **`#197` Bug 2a** — `valueFrom.secretKeyRef` on `ActorTemplate` container env is not supported today. If your Antigravity command needs a token or API key, pass it as a plain `value:` env var until upstream support lands. - **`#197` Bug 3** — Atelet symlink resolution. ## Teardown ```bash -./hack/install-ate.sh --delete-demo-gemini-cli-multiplex +./hack/install-ate.sh --delete-demo-antigravity-cli-multiplex ``` -This removes the `gemini-cli-multiplex-demo` namespace and all the resources created by the deploy step. You can also stop the port-forward and the dashboard processes in their respective terminals. +This removes the `antigravity-cli-multiplex-demo` namespace and all the resources created by the deploy step. You can also stop the port-forward and the dashboard processes in their respective terminals. diff --git a/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl b/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl similarity index 75% rename from demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl rename to demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl index 2f9bbdf29..a9b48011c 100644 --- a/demos/gemini-cli-multiplex/gemini-cli-multiplex.yaml.tmpl +++ b/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl @@ -13,19 +13,19 @@ # limitations under the License. # Three ActorTemplates share a 2-pod WorkerPool, so substrate must suspend -# at least one actor at any moment. GEMINI_API_KEY is passed as a plain -# env var per dberkov's PR #203 review — substrate does not currently +# at least one actor at any moment. If your Antigravity command needs a +# secret, pass it as a plain env var for now — substrate does not currently # support `valueFrom.secretKeyRef` on ActorTemplate container env. # # WORKLOAD_IMAGE is the resolved sha256-digest reference for the -# gemini-cli-multiplex-demo-workload image — built and pushed to -# ${KO_DOCKER_REPO} by `./hack/install-ate.sh --deploy-demo-gemini-cli-multiplex`, +# antigravity-cli-multiplex-demo-workload image — built and pushed to +# ${KO_DOCKER_REPO} by `./hack/install-ate.sh --deploy-demo-antigravity-cli-multiplex`, # substituted into this template at apply time. apiVersion: v1 kind: Namespace metadata: - name: gemini-cli-multiplex-demo + name: antigravity-cli-multiplex-demo --- @@ -34,8 +34,8 @@ metadata: apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: - name: gemini-workerpool - namespace: gemini-cli-multiplex-demo + name: antigravity-workerpool + namespace: antigravity-cli-multiplex-demo spec: replicas: 2 ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor @@ -51,7 +51,7 @@ apiVersion: ate.dev/v1alpha1 kind: ActorTemplate metadata: name: agent-luna - namespace: gemini-cli-multiplex-demo + namespace: antigravity-cli-multiplex-demo spec: runsc: amd64: @@ -62,7 +62,7 @@ spec: sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9" pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" containers: - - name: gemini + - name: antigravity image: ${WORKLOAD_IMAGE} command: ["/run.sh"] env: @@ -72,15 +72,13 @@ spec: value: "Tell me one short, surprising fact about the Moon. One sentence." - name: INTERVAL_SECONDS value: "45" - - name: GEMINI_MODEL - value: "gemini-2.5-flash" - - name: GEMINI_API_KEY - value: "${GEMINI_API_KEY}" + - name: ANTIGRAVITY_COMMAND + value: "antigravity --help" workerPoolRef: - namespace: gemini-cli-multiplex-demo - name: gemini-workerpool + namespace: antigravity-cli-multiplex-demo + name: antigravity-workerpool snapshotsConfig: - location: gs://${BUCKET_NAME}/gemini-cli-multiplex-demo/ + location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/ --- @@ -88,7 +86,7 @@ apiVersion: ate.dev/v1alpha1 kind: ActorTemplate metadata: name: agent-mars - namespace: gemini-cli-multiplex-demo + namespace: antigravity-cli-multiplex-demo spec: runsc: amd64: @@ -99,7 +97,7 @@ spec: sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9" pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" containers: - - name: gemini + - name: antigravity image: ${WORKLOAD_IMAGE} command: ["/run.sh"] env: @@ -109,15 +107,13 @@ spec: value: "Give me one concise tip for learning a new programming language. One sentence." - name: INTERVAL_SECONDS value: "45" - - name: GEMINI_MODEL - value: "gemini-2.5-flash" - - name: GEMINI_API_KEY - value: "${GEMINI_API_KEY}" + - name: ANTIGRAVITY_COMMAND + value: "antigravity --help" workerPoolRef: - namespace: gemini-cli-multiplex-demo - name: gemini-workerpool + namespace: antigravity-cli-multiplex-demo + name: antigravity-workerpool snapshotsConfig: - location: gs://${BUCKET_NAME}/gemini-cli-multiplex-demo/ + location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/ --- @@ -125,7 +121,7 @@ apiVersion: ate.dev/v1alpha1 kind: ActorTemplate metadata: name: agent-orion - namespace: gemini-cli-multiplex-demo + namespace: antigravity-cli-multiplex-demo spec: runsc: amd64: @@ -136,7 +132,7 @@ spec: sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9" pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" containers: - - name: gemini + - name: antigravity image: ${WORKLOAD_IMAGE} command: ["/run.sh"] env: @@ -146,12 +142,10 @@ spec: value: "Suggest one healthy meal that takes under 15 minutes to prepare. One sentence." - name: INTERVAL_SECONDS value: "45" - - name: GEMINI_MODEL - value: "gemini-2.5-flash" - - name: GEMINI_API_KEY - value: "${GEMINI_API_KEY}" + - name: ANTIGRAVITY_COMMAND + value: "antigravity --help" workerPoolRef: - namespace: gemini-cli-multiplex-demo - name: gemini-workerpool + namespace: antigravity-cli-multiplex-demo + name: antigravity-workerpool snapshotsConfig: - location: gs://${BUCKET_NAME}/gemini-cli-multiplex-demo/ + location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/ diff --git a/demos/gemini-cli-multiplex/ui/index.html b/demos/antigravity-cli-multiplex/ui/index.html similarity index 97% rename from demos/gemini-cli-multiplex/ui/index.html rename to demos/antigravity-cli-multiplex/ui/index.html index 72bc4f845..2a35dd1a1 100644 --- a/demos/gemini-cli-multiplex/ui/index.html +++ b/demos/antigravity-cli-multiplex/ui/index.html @@ -18,7 +18,7 @@ -Gemini CLI multiplex demo +Antigravity CLI multiplex demo