diff --git a/demos/antigravity-cli-multiplex/README.md b/demos/antigravity-cli-multiplex/README.md new file mode 100644 index 000000000..66a838a0b --- /dev/null +++ b/demos/antigravity-cli-multiplex/README.md @@ -0,0 +1,123 @@ +# Antigravity CLI Multiplex Demo + +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 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. + +## 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 + ``` +- 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` 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/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 substrate bucket name in the environment: + +```bash +BUCKET_NAME=your-substrate-bucket \ + ./hack/install-ate.sh --deploy-demo-antigravity-cli-multiplex +``` + +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 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 antigravity-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/antigravity-cli-multiplex/ui +PORT=8090 ATEAPI_ADDR=localhost:8080 go run . +``` + +Or build a binary: + +```bash +cd demos/antigravity-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` | `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. + +### 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 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. + +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. 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-antigravity-cli-multiplex +``` + +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/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl b/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl new file mode 100644 index 000000000..a9b48011c --- /dev/null +++ b/demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl @@ -0,0 +1,151 @@ +# 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. 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 +# 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: antigravity-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: antigravity-workerpool + namespace: antigravity-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: antigravity-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: antigravity + image: ${WORKLOAD_IMAGE} + command: ["/run.sh"] + 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: ANTIGRAVITY_COMMAND + value: "antigravity --help" + workerPoolRef: + namespace: antigravity-cli-multiplex-demo + name: antigravity-workerpool + snapshotsConfig: + location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/ + +--- + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: agent-mars + namespace: antigravity-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: antigravity + image: ${WORKLOAD_IMAGE} + command: ["/run.sh"] + 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: ANTIGRAVITY_COMMAND + value: "antigravity --help" + workerPoolRef: + namespace: antigravity-cli-multiplex-demo + name: antigravity-workerpool + snapshotsConfig: + location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/ + +--- + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: agent-orion + namespace: antigravity-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: antigravity + image: ${WORKLOAD_IMAGE} + command: ["/run.sh"] + 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: ANTIGRAVITY_COMMAND + value: "antigravity --help" + workerPoolRef: + namespace: antigravity-cli-multiplex-demo + name: antigravity-workerpool + snapshotsConfig: + location: gs://${BUCKET_NAME}/antigravity-cli-multiplex-demo/ diff --git a/demos/antigravity-cli-multiplex/ui/index.html b/demos/antigravity-cli-multiplex/ui/index.html new file mode 100644 index 000000000..2a35dd1a1 --- /dev/null +++ b/demos/antigravity-cli-multiplex/ui/index.html @@ -0,0 +1,424 @@ + + + + + + +Antigravity CLI multiplex demo + + + +
+

Antigravity CLI multiplex demo

+
connecting…
+
+ +

+ This demo runs 3 Antigravity agents on + 2 substrate worker pods. Each agent has its own short prompt + and works in a loop: run the configured Antigravity command, print the output, 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 + Antigravity usage (3 agents): depends on your plan + Total: ~$0.90/hr typical + + GCP figure is one n2-standard-8 VM in us-central1 (ephemeral IP free). Antigravity usage + depends on the account and model configuration, each agent firing one short command 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 Antigravity, 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/antigravity-cli-multiplex/ui/server.go b/demos/antigravity-cli-multiplex/ui/server.go new file mode 100644 index 000000000..2d53ea5cc --- /dev/null +++ b/demos/antigravity-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 (Antigravity 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=antigravity-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 = "antigravity-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/antigravity-cli-multiplex/workload/Dockerfile b/demos/antigravity-cli-multiplex/workload/Dockerfile new file mode 100644 index 000000000..763fe0835 --- /dev/null +++ b/demos/antigravity-cli-multiplex/workload/Dockerfile @@ -0,0 +1,41 @@ +# 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 debian:bookworm-slim + +ARG ANTIGRAVITY_VERSION=1.23.2-1776332190 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://us-central1-apt.pkg.dev/doc/repo-signing-key.gpg \ + | gpg --dearmor --yes -o /etc/apt/keyrings/antigravity-repo-key.gpg \ + && printf '%s\n' 'deb [signed-by=/etc/apt/keyrings/antigravity-repo-key.gpg] https://us-central1-apt.pkg.dev/projects/antigravity-auto-updater-dev/ antigravity-debian main' \ + > /etc/apt/sources.list.d/antigravity.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends "antigravity=${ANTIGRAVITY_VERSION}" \ + && rm -rf /var/lib/apt/lists/* + +COPY run.sh /run.sh +RUN chmod +x /run.sh + +RUN useradd --create-home --shell /bin/bash antigravity +USER antigravity +WORKDIR /home/antigravity + +ENV TASK="Tell me a fact about the moon." +ENV INTERVAL_SECONDS=60 +ENV ANTIGRAVITY_COMMAND="antigravity --help" + +ENTRYPOINT ["/run.sh"] diff --git a/demos/antigravity-cli-multiplex/workload/run.sh b/demos/antigravity-cli-multiplex/workload/run.sh new file mode 100644 index 000000000..a64ab6bba --- /dev/null +++ b/demos/antigravity-cli-multiplex/workload/run.sh @@ -0,0 +1,53 @@ +#!/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 Antigravity 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 available to ANTIGRAVITY_COMMAND each tick +# INTERVAL_SECONDS — sleep length between ticks (longer = more multiplex headroom) +# ANTIGRAVITY_COMMAND — command to run once per tick; TASK is exported + +set -u + +if [ -z "${ANTIGRAVITY_COMMAND:-}" ]; then + echo "[demo-actor] ERROR: ANTIGRAVITY_COMMAND not set; refusing to start" >&2 + exit 1 +fi + +ACTOR_NAME="${ACTOR_NAME:-$(hostname)}" +TICK=0 + +export TASK + +echo "[demo-actor:${ACTOR_NAME}] starting; task=\"${TASK}\" interval=${INTERVAL_SECONDS}s command=\"${ANTIGRAVITY_COMMAND}\"" + +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 "---" + # Output streams to stdout so kubectl logs picks it up live. Keep the + # Antigravity invocation configurable until its official headless prompt + # flags are documented. + sh -c "${ANTIGRAVITY_COMMAND}" 2>&1 || echo "[demo-actor:${ACTOR_NAME}] antigravity command 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..50856b2ec 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-antigravity-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-antigravity-cli-multiplex.sh b/hack/install-demo-antigravity-cli-multiplex.sh new file mode 100644 index 000000000..1c92eaeba --- /dev/null +++ b/hack/install-demo-antigravity-cli-multiplex.sh @@ -0,0 +1,94 @@ +#!/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-antigravity-cli-multiplex) # register demo-antigravity-cli-multiplex + +demo-antigravity-cli-multiplex_cmdline() { + case "${1}" in + --deploy-demo-antigravity-cli-multiplex) demo-antigravity-cli-multiplex_deploy ;; + --delete-demo-antigravity-cli-multiplex) demo-antigravity-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/.../antigravity-cli-multiplex-demo-workload@sha256:...). +# The workload is a Dockerfile-based Antigravity wrapper (not a Go binary), so +# it uses docker buildx rather than ko. +demo-antigravity-cli-multiplex_build_workload() { + local repo="${KO_DOCKER_REPO}/antigravity-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/antigravity-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-antigravity-cli-multiplex_deploy() { + log_step "demo-antigravity-cli-multiplex_deploy" + 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-antigravity-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|\${WORKLOAD_IMAGE}|${workload_image}|g" \ + demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl \ + | run_ko apply -f - +} + +demo-antigravity-cli-multiplex_delete() { + log_step "demo-antigravity-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|\${WORKLOAD_IMAGE}|placeholder|g" \ + demos/antigravity-cli-multiplex/antigravity-cli-multiplex.yaml.tmpl \ + | run_kubectl delete --ignore-not-found -f - +} + +demo-antigravity-cli-multiplex_usage() { + echo "" + echo " Required env: BUCKET_NAME, KO_DOCKER_REPO" + echo " See demos/antigravity-cli-multiplex/README.md for the walkthrough." +}