diff --git a/.github/workflows/nexus-cd.yaml b/.github/workflows/nexus-cd.yaml
index 3136550a46..e01c5c4b17 100644
--- a/.github/workflows/nexus-cd.yaml
+++ b/.github/workflows/nexus-cd.yaml
@@ -39,11 +39,11 @@ jobs:
working-directory: go/nexus
steps:
- uses: actions/checkout@v7
+ # module cache is ~2.5GB and was evicting every other Actions cache in the repo
- uses: actions/setup-go@v7
with:
go-version-file: go/nexus/go.mod
- cache-dependency-path: go/nexus/go.sum
- cache: true
+ cache: false
- run: go mod download
- run: PATH=$PATH:$GOPATH/bin make test
@@ -114,8 +114,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platforms.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- cache-from: type=gha,scope=nexus-${{ env.PLATFORM_PAIR }}
- cache-to: type=gha,mode=max,scope=nexus-${{ env.PLATFORM_PAIR }}
+ cache-from: type=registry,ref=ghcr.io/pluralsh/console-cache:nexus-${{ env.PLATFORM_PAIR }}
+ cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && format('type=registry,ref=ghcr.io/pluralsh/console-cache:nexus-{0},mode=max', env.PLATFORM_PAIR) || '' }}
build-args: |
GIT_COMMIT=${{ github.sha }}
VERSION=${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/nexus-ci.yaml b/.github/workflows/nexus-ci.yaml
index e261b70c9f..a93916440f 100644
--- a/.github/workflows/nexus-ci.yaml
+++ b/.github/workflows/nexus-ci.yaml
@@ -38,7 +38,7 @@ jobs:
uses: actions/setup-go@v7
with:
go-version-file: go/nexus/go.mod
- cache-dependency-path: go/nexus/go.sum
+ cache: false
- name: Build binary
run: make build
@@ -97,8 +97,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platforms.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- cache-from: type=gha,scope=nexus-${{ env.PLATFORM_PAIR }}
- cache-to: type=gha,mode=max,scope=nexus-${{ env.PLATFORM_PAIR }}
+ # read-only: written by master builds in nexus-cd.yaml
+ cache-from: type=registry,ref=ghcr.io/pluralsh/console-cache:nexus-${{ env.PLATFORM_PAIR }}
build-args: |
GIT_COMMIT=${{ github.sha }}
VERSION=${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/observability-proxy-cd.yaml b/.github/workflows/observability-proxy-cd.yaml
index 92186afae5..76df5e994c 100644
--- a/.github/workflows/observability-proxy-cd.yaml
+++ b/.github/workflows/observability-proxy-cd.yaml
@@ -38,11 +38,11 @@ jobs:
working-directory: go/observability-proxy
steps:
- uses: actions/checkout@v7
+ # module cache is ~2.5GB and was evicting every other Actions cache in the repo
- uses: actions/setup-go@v7
with:
go-version-file: go/observability-proxy/go.mod
- cache-dependency-path: go/observability-proxy/go.sum
- cache: true
+ cache: false
- run: go mod download
- run: GOCACHE=/tmp/go-build go test ./...
@@ -118,8 +118,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platforms.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- cache-from: type=gha,scope=observability-proxy-${{ env.PLATFORM_PAIR }}
- cache-to: type=gha,mode=max,scope=observability-proxy-${{ env.PLATFORM_PAIR }}
+ cache-from: type=registry,ref=ghcr.io/pluralsh/console-cache:observability-proxy-${{ env.PLATFORM_PAIR }}
+ cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && format('type=registry,ref=ghcr.io/pluralsh/console-cache:observability-proxy-{0},mode=max', env.PLATFORM_PAIR) || '' }}
- name: Export digest
run: |
diff --git a/.github/workflows/observability-proxy-ci.yaml b/.github/workflows/observability-proxy-ci.yaml
index 59c5f6e778..61677a36c7 100644
--- a/.github/workflows/observability-proxy-ci.yaml
+++ b/.github/workflows/observability-proxy-ci.yaml
@@ -37,7 +37,7 @@ jobs:
uses: actions/setup-go@v7
with:
go-version-file: go/observability-proxy/go.mod
- cache-dependency-path: go/observability-proxy/go.sum
+ cache: false
- name: Run tests
run: go test ./...
@@ -97,8 +97,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platforms.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- cache-from: type=gha,scope=observability-proxy-${{ env.PLATFORM_PAIR }}
- cache-to: type=gha,mode=max,scope=observability-proxy-${{ env.PLATFORM_PAIR }}
+ # read-only: written by master builds in observability-proxy-cd.yaml
+ cache-from: type=registry,ref=ghcr.io/pluralsh/console-cache:observability-proxy-${{ env.PLATFORM_PAIR }}
- name: Export digest
run: |
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index c20d4a68f2..db93505d23 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -7,13 +7,16 @@ on:
jobs:
test:
runs-on: ubuntu-latest
+ env:
+ MIX_ENV: test
+ MIX_OS_DEPS_COMPILE_PARTITION_COUNT: '4'
+ HEX_HTTP_CONCURRENCY: '10'
steps:
- uses: actions/checkout@v7
- uses: erlef/setup-beam@v1
with:
version-file: .tool-versions
version-type: strict
- - uses: dtolnay/rust-toolchain@stable
- uses: azure/setup-helm@v5
with:
version: latest
@@ -23,29 +26,15 @@ jobs:
username: mjgpluralsh
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- run: make testup
- - name: Restore dependencies cache
- uses: actions/cache@v6
- with:
- path: deps
- key: ${{ runner.os }}-deps-v2-${{ hashFiles('.tool-versions', '**/mix.lock') }}
- restore-keys: ${{ runner.os }}-deps-v2-
- - name: Restore NIF cache
- uses: actions/cache@v6
- with:
- path: priv/native
- key: ${{ runner.os }}-nif-v2-${{ hashFiles('.tool-versions', '**/mix.lock', 'native/**', 'lib/console/mermaid_validator.ex') }}
- restore-keys: ${{ runner.os }}-nif-v2-
- - name: Restore _build cache
- uses: actions/cache@v6
- with:
- path: _build
- key: ${{ runner.os }}-build-v2-${{ hashFiles('.tool-versions', '**/mix.lock', 'mix.exs', 'lib/**/*.ex', 'config/**/*.exs', 'native/**') }}
- # - name: download jwt verification key
- # run: |
- # echo $JWT_PUBLIC_KEY > config/pubkey.pem
- # env:
- # JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }}
- - run: mix deps.get
+ - uses: actions/cache@v6
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-test-v4-${{ hashFiles('.tool-versions', 'mix.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-mix-test-v4-
+ - run: mix deps.get --check-locked
- run: mix test
- uses: 8398a7/action-slack@v3
with:
@@ -65,8 +54,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
- with:
- fetch-depth: 0
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
@@ -127,8 +114,8 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
- cache-from: type=gha,scope=console
- cache-to: type=gha,mode=max,scope=console
+ # read-only: the shared cache is only written by master builds in test.yaml
+ cache-from: type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache
build-args: |
GIT_COMMIT=${{ github.sha }}
VITE_PROD_SECRET_KEY=${{ secrets.VITE_PROD_SECRET_KEY }}
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 58bfdfd01b..77f73529a8 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -2,6 +2,9 @@ name: CI / Console
env:
DOCKER_METADATA_PR_HEAD_SHA: 'true'
+ MIX_ENV: test
+ MIX_OS_DEPS_COMPILE_PARTITION_COUNT: '4'
+ HEX_HTTP_CONCURRENCY: '10'
on:
push:
@@ -26,8 +29,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
- with:
- fetch-depth: 0
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
@@ -80,8 +81,12 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
- cache-from: type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache
- cache-to: type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache,mode=max
+ cache-from: |
+ type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache
+ type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache-pr-${{ github.event.pull_request.number }}
+ # master writes the shared cache; PRs write a per-PR tag so later
+ # pushes reuse Mix layers without seeding unmerged work for everyone
+ cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && 'type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache,mode=max' || format('type=registry,ref=ghcr.io/pluralsh/console-cache:buildcache-pr-{0},mode=max', github.event.pull_request.number) }}
build-args: |
GIT_COMMIT=${{ github.sha }}
VITE_PROD_SECRET_KEY=${{ secrets.VITE_PROD_SECRET_KEY }}
@@ -117,7 +122,6 @@ jobs:
with:
version-file: .tool-versions
version-type: strict
- - uses: dtolnay/rust-toolchain@stable
- uses: azure/setup-helm@v5
with:
version: latest
@@ -127,29 +131,16 @@ jobs:
username: mjgpluralsh
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- run: make testup
- # - name: download jwt verification key
- # run: |
- # echo $JWT_PUBLIC_KEY > config/pubkey.pem
- # env:
- # JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }}
- - name: Restore dependencies cache
- uses: actions/cache@v6
- with:
- path: deps
- key: ${{ runner.os }}-deps-v2-${{ hashFiles('.tool-versions', '**/mix.lock') }}
- restore-keys: ${{ runner.os }}-deps-v2-
- - name: Restore NIF cache
- uses: actions/cache@v6
- with:
- path: priv/native
- key: ${{ runner.os }}-nif-v2-${{ hashFiles('.tool-versions', '**/mix.lock', 'native/**', 'lib/console/mermaid_validator.ex') }}
- restore-keys: ${{ runner.os }}-nif-v2-
- - name: Restore _build cache
- uses: actions/cache@v6
- with:
- path: _build
- key: ${{ runner.os }}-build-v2-${{ hashFiles('.tool-versions', '**/mix.lock', 'mix.exs', 'config/**/*.exs', 'native/**') }}
- - run: mix deps.get
+ - uses: actions/cache@v6
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-test-v4-${{ hashFiles('.tool-versions', 'mix.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-mix-test-v4-
+ - run: mix deps.get --check-locked
+ - run: mix compile
- run: mix test
env:
TEST_CHART_OCI_PASSWORD: ${{ secrets.GITHUB_TOKEN}}
@@ -170,42 +161,33 @@ jobs:
needs: [vouch]
name: Check that Schema is up to date
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@v7
- uses: erlef/setup-beam@v1
with:
version-file: .tool-versions
version-type: strict
- - uses: dtolnay/rust-toolchain@stable
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go/client/go.mod
cache-dependency-path: go/client/go.sum
cache: true
- - name: Restore dependencies cache
- uses: actions/cache@v6
- with:
- path: deps
- key: ${{ runner.os }}-deps-v2-${{ hashFiles('.tool-versions', '**/mix.lock') }}
- restore-keys: ${{ runner.os }}-deps-v2-
- - name: Restore NIF cache
- uses: actions/cache@v6
- with:
- path: priv/native
- key: ${{ runner.os }}-nif-v2-${{ hashFiles('.tool-versions', '**/mix.lock', 'native/**', 'lib/console/mermaid_validator.ex') }}
- restore-keys: ${{ runner.os }}-nif-v2-
- - name: Restore _build cache
- uses: actions/cache@v6
- with:
- path: _build
- key: ${{ runner.os }}-build-v2-${{ hashFiles('.tool-versions', '**/mix.lock', 'mix.exs', 'lib/**/*.ex', 'config/**/*.exs', 'native/**') }}
- - name: get dependencies
- run: mix deps.get
+ - uses: actions/cache@v6
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-test-v4-${{ hashFiles('.tool-versions', 'mix.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-mix-test-v4-
+ - run: mix deps.get --check-locked
- name: update schema
- run: MIX_ENV=test mix absinthe.schema.sdl --schema Console.GraphQl schema/schema.graphql
+ run: mix absinthe.schema.sdl --schema Console.GraphQl schema/schema.graphql
- name: update openapi schema
- run: MIX_ENV=test mix openapi.dump Console.OpenAPI --pretty -o schema/openapi.json
+ run: mix openapi.dump Console.OpenAPI --pretty -o schema/openapi.json
- name: Verify Changed files
run: |
git diff --exit-code -- schema || {
diff --git a/Dockerfile b/Dockerfile
index 0552259ece..2046673558 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -55,7 +55,8 @@ ARG OS_VARIANT=alpine
ENV SKIP_PHOENIX=${SKIP_PHOENIX} \
APP_NAME=${APP_NAME} \
MIX_ENV=${MIX_ENV} \
- OS_VARIANT=${OS_VARIANT}
+ OS_VARIANT=${OS_VARIANT} \
+ MIX_OS_DEPS_COMPILE_PARTITION_COUNT=4
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:${PATH}
@@ -64,10 +65,10 @@ ARG RUST_TOOLCHAIN=stable
# By convention, /opt is typically used for applications
WORKDIR /opt/app
-# This step installs build tools for C NIFs (e.g. argon2_elixir). Rust-based deps use
-# precompiled NIFs and do not require a Rust toolchain in the Alpine builder image.
+# Install build tools only. Do not `apk upgrade` here: floating package
+# versions change the layer digest and force a full Mix deps recompile.
+# Rust-based deps use precompiled NIFs and do not need a toolchain on Alpine.
RUN if [ "$OS_VARIANT" = "alpine" ]; then \
- apk update && apk upgrade --no-cache && \
apk add --no-cache git build-base curl ca-certificates; \
else \
apt-get update && apt-get install -y --no-install-recommends git build-essential curl ca-certificates; \
@@ -78,14 +79,23 @@ RUN if [ "$OS_VARIANT" = "alpine" ]; then \
mix local.rebar --force && \
mix local.hex --force
-# This copies our app source code into the build container
-COPY . .
-
-# needed so that we can get the app version from the git tag
-RUN git config --global --add safe.directory '/opt/app'
-
-RUN mix do deps.get, compile
-RUN ls -al
+# Deps are fetched and compiled in their own layer so they stay cached until mix.lock or build config changes
+COPY mix.exs mix.lock ./
+COPY config/config.exs config/${MIX_ENV}.exs config/
+RUN mix do deps.get --only ${MIX_ENV} + deps.compile
+
+# Only copy what the app compile reads, so changes elsewhere in the monorepo don't bust this layer
+COPY config/ config/
+COPY src/ src/
+COPY static/ static/
+COPY priv/ priv/
+COPY rel/ rel/
+COPY lib/ lib/
+COPY AGENT_VERSION KUBE_VERSION ./
+COPY charts/controller/crds/ charts/controller/crds/
+COPY go/client/generated/persisted-queries/queries.json go/client/generated/persisted-queries/
+COPY js/console/src/generated/persisted-queries/client.json js/console/src/generated/persisted-queries/
+RUN mix compile
COPY --from=node /app/console/build ./priv/static
diff --git a/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile
index 87c393cd31..6dbba60e66 100644
--- a/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile
+++ b/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile
@@ -1,3 +1,6 @@
+ARG TERRAFORM_VERSION=1.16.4
+ARG HELM_VERSION=4.3.0
+
FROM golang:1.27.1-bookworm AS builder
ARG TARGETARCH
@@ -75,6 +78,9 @@ RUN set -eux; \
nix-store -qR "${podman_output}" | \
while read -r path; do cp -a --parents "${path}" /closure; done
+FROM hashicorp/terraform:${TERRAFORM_VERSION} AS terraform
+FROM alpine/helm:${HELM_VERSION} AS helm
+
FROM dhi.io/debian-base:trixie-dev
ARG TARGETARCH
@@ -187,6 +193,9 @@ COPY --from=builder /agent-harness /agent-harness
COPY --from=builder /agent-mcpserver /agent-mcpserver
COPY --from=builder /agent-bootstrap /agent-bootstrap
COPY --from=fcp /opt/fcp/bin/fcp /usr/local/bin/fcp
+COPY --from=terraform /bin/terraform /usr/local/bin/terraform
+COPY --from=helm /usr/bin/helm /usr/local/bin/helm
+RUN terraform version && helm version
# Pin mise in the base agent image. The harness looks it up on PATH and does
# not download an installer at runtime. See https://mise.jdx.dev/bootstrap.html
diff --git a/go/deployment-operator/pkg/agentrun-harness/controller/controller.go b/go/deployment-operator/pkg/agentrun-harness/controller/controller.go
index 4fc1b92fd3..fe3e4176fe 100644
--- a/go/deployment-operator/pkg/agentrun-harness/controller/controller.go
+++ b/go/deployment-operator/pkg/agentrun-harness/controller/controller.go
@@ -104,9 +104,6 @@ func (in *agentRunController) prepare(ctx context.Context) error {
if err := environment.ConfigurePrebakeGitSafeDirectories(); err != nil {
return fmt.Errorf("configure prebake git safe directories: %w", err)
}
- if err := in.checkoutFollowupBranch(ctx, repositoryDir); err != nil {
- return err
- }
if in.tool, err = tool.New(in.agentRun.Runtime.Type, toolv1.Config{
WorkDir: in.dir,
RepositoryDir: repositoryDir,
@@ -121,36 +118,6 @@ func (in *agentRunController) prepare(ctx context.Context) error {
return in.tool.Configure(in.consoleUrl, *in.agentRun.PluralCreds.Token)
}
-func (in *agentRunController) checkoutFollowupBranch(ctx context.Context, repositoryDir string) error {
- if in.agentRun == nil || !in.agentRun.Followup {
- return nil
- }
-
- headBranch := ""
- if in.agentRun.HeadBranch != nil {
- headBranch = strings.TrimSpace(*in.agentRun.HeadBranch)
- }
- if headBranch == "" {
- return fmt.Errorf("follow-up agent run requires a head branch to check out")
- }
-
- if output, err := exec.NewExecutable("git",
- exec.WithArgs([]string{"fetch", "origin", headBranch}),
- exec.WithDir(repositoryDir),
- ).RunWithOutput(ctx); err != nil {
- return fmt.Errorf("fetch follow-up head branch %q: %w: %s", headBranch, err, output)
- }
-
- if output, err := exec.NewExecutable("git",
- exec.WithArgs([]string{"checkout", "-B", headBranch, "origin/" + headBranch}),
- exec.WithDir(repositoryDir),
- ).RunWithOutput(ctx); err != nil {
- return fmt.Errorf("check out follow-up head branch %q: %w: %s", headBranch, err, output)
- }
-
- return nil
-}
-
// completeAgentRun updates the agent run status in the Console API
func (in *agentRunController) completeAgentRun(status gqlclient.AgentRunStatus, agentRunErr error) error {
var errorMsg *string
diff --git a/go/deployment-operator/pkg/agentrun-harness/controller/followup_checkout_test.go b/go/deployment-operator/pkg/agentrun-harness/controller/followup_checkout_test.go
deleted file mode 100644
index 6facaec72d..0000000000
--- a/go/deployment-operator/pkg/agentrun-harness/controller/followup_checkout_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package controller
-
-import (
- "context"
- "testing"
-
- "github.com/stretchr/testify/require"
-
- agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1"
-)
-
-func TestCheckoutFollowupBranchRequiresHeadBranch(t *testing.T) {
- controller := &agentRunController{
- agentRun: &agentrunv1.AgentRun{Followup: true},
- }
-
- err := controller.checkoutFollowupBranch(context.Background(), t.TempDir())
-
- require.EqualError(t, err, "follow-up agent run requires a head branch to check out")
-}
-
-func TestCheckoutFollowupBranchSkipsRegularRuns(t *testing.T) {
- controller := &agentRunController{
- agentRun: &agentrunv1.AgentRun{},
- }
-
- require.NoError(t, controller.checkoutFollowupBranch(context.Background(), t.TempDir()))
-}
diff --git a/go/deployment-operator/pkg/agentrun-harness/environment/environment.go b/go/deployment-operator/pkg/agentrun-harness/environment/environment.go
index 45c1ef3dc4..510d74d6b5 100644
--- a/go/deployment-operator/pkg/agentrun-harness/environment/environment.go
+++ b/go/deployment-operator/pkg/agentrun-harness/environment/environment.go
@@ -76,7 +76,7 @@ func (in *environment) cloneRepository() error {
if err := in.checkoutRequestedBranch(repoDirPath); err != nil {
return err
}
- return in.configureRepository(repoDirPath, userName, userEmail)
+ return in.finalizeRepository(repoDirPath, userName, userEmail)
}
copied, err := in.cloneFromPrebake(repoDirPath)
@@ -84,7 +84,7 @@ func (in *environment) cloneRepository() error {
return err
}
if copied {
- return in.configureRepository(repoDirPath, userName, userEmail)
+ return in.finalizeRepository(repoDirPath, userName, userEmail)
}
// Set proxy for clone via environment variable so it takes effect immediately.
@@ -110,7 +110,7 @@ func (in *environment) cloneRepository() error {
}
repoDirPath = path.Join(in.dir, repoDir)
- return in.configureRepository(repoDirPath, userName, userEmail)
+ return in.finalizeRepository(repoDirPath, userName, userEmail)
}
func (in *environment) cloneFromPrebake(repoDirPath string) (bool, error) {
@@ -307,6 +307,40 @@ func (in *environment) checkoutRequestedBranch(repoDirPath string) error {
return nil
}
+func (in *environment) finalizeRepository(repoDirPath, userName, userEmail string) error {
+ if err := in.checkoutFollowupBranch(repoDirPath); err != nil {
+ return err
+ }
+ return in.configureRepository(repoDirPath, userName, userEmail)
+}
+
+func (in *environment) checkoutFollowupBranch(repoDirPath string) error {
+ if in.agentRun == nil || !in.agentRun.Followup {
+ return nil
+ }
+
+ headBranch := strings.TrimSpace(lo.FromPtr(in.agentRun.HeadBranch))
+ if headBranch == "" {
+ return fmt.Errorf("follow-up agent run requires a head branch to check out")
+ }
+
+ if output, err := exec.NewExecutable("git",
+ exec.WithArgs([]string{"fetch", "origin", headBranch}),
+ exec.WithDir(repoDirPath),
+ ).RunWithOutput(context.Background()); err != nil {
+ return fmt.Errorf("fetch follow-up head branch %q: %w: %s", headBranch, err, output)
+ }
+
+ if output, err := exec.NewExecutable("git",
+ exec.WithArgs([]string{"checkout", "-B", headBranch, "origin/" + headBranch}),
+ exec.WithDir(repoDirPath),
+ ).RunWithOutput(context.Background()); err != nil {
+ return fmt.Errorf("check out follow-up head branch %q: %w: %s", headBranch, err, output)
+ }
+
+ return nil
+}
+
func (in *environment) configureRepository(repoDirPath, userName, userEmail string) error {
if userName != "" {
if err := exec.NewExecutable("git",
diff --git a/go/deployment-operator/pkg/agentrun-harness/environment/environment_test.go b/go/deployment-operator/pkg/agentrun-harness/environment/environment_test.go
index ae54a02cb3..2caec76fe1 100644
--- a/go/deployment-operator/pkg/agentrun-harness/environment/environment_test.go
+++ b/go/deployment-operator/pkg/agentrun-harness/environment/environment_test.go
@@ -362,6 +362,73 @@ func TestCloneRepositoryChecksOutRunBranchFromOrigin(t *testing.T) {
}
}
+func TestCloneRepositoryChecksOutFollowupHeadBranch(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
+ t.Setenv("TMPDIR", t.TempDir())
+ runGit(t, home, "config", "--global", "--add", "safe.directory", "*")
+
+ origin := initGitRepo(t, "main")
+ runGit(t, origin, "checkout", "-b", "feature")
+ if err := os.WriteFile(filepath.Join(origin, "README"), []byte("feature\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, origin, "add", "README")
+ runGit(t, origin, "commit", "-m", "feature")
+ runGit(t, origin, "checkout", "main")
+
+ baseBranch := "main"
+ headBranch := "feature"
+ workDir := t.TempDir()
+ env := &environment{
+ agentRun: &v1.AgentRun{
+ Repository: origin,
+ Branch: &baseBranch,
+ HeadBranch: &headBranch,
+ Followup: true,
+ },
+ dir: workDir,
+ }
+ if err := env.cloneRepository(); err != nil {
+ t.Fatalf("cloneRepository() failed: %v", err)
+ }
+
+ dest := filepath.Join(workDir, "repository")
+ contents, err := os.ReadFile(filepath.Join(dest, "README"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(contents) != "feature\n" {
+ t.Fatalf("copied README = %q, want feature after checking out follow-up head branch", contents)
+ }
+ current, err := exec.Command("git", "-C", dest, "branch", "--show-current").Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.TrimSpace(string(current)); got != headBranch {
+ t.Fatalf("branch = %q, want %s", got, headBranch)
+ }
+}
+
+func TestCheckoutFollowupBranchRequiresHeadBranch(t *testing.T) {
+ env := &environment{agentRun: &v1.AgentRun{Followup: true}}
+
+ err := env.checkoutFollowupBranch(t.TempDir())
+
+ if err == nil || err.Error() != "follow-up agent run requires a head branch to check out" {
+ t.Fatalf("checkoutFollowupBranch() error = %v", err)
+ }
+}
+
+func TestCheckoutFollowupBranchSkipsRegularRuns(t *testing.T) {
+ env := &environment{agentRun: &v1.AgentRun{}}
+
+ if err := env.checkoutFollowupBranch(t.TempDir()); err != nil {
+ t.Fatalf("checkoutFollowupBranch() error = %v", err)
+ }
+}
+
func TestCloneRepositoryFallsBackToGitClone(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
diff --git a/js/console/src/components/ai/chatbot/ChatMessageContent.tsx b/js/console/src/components/ai/chatbot/ChatMessageContent.tsx
index 2388d38c0c..b518b9f40f 100644
--- a/js/console/src/components/ai/chatbot/ChatMessageContent.tsx
+++ b/js/console/src/components/ai/chatbot/ChatMessageContent.tsx
@@ -39,6 +39,8 @@ import { AgentRunInfoCard } from '../agent-runs/AgentRunInfoDisplays.tsx'
import { ChatMarkdown } from './ChatMarkdown'
import { ChatMessageActions } from './ChatMessage'
import { SimpleToolCall } from './multithread/MultiThreadViewerMessage.tsx'
+import { resolveToolCallKind } from './toolCallDisplay'
+import { ToolCallKindIcon } from './toolCallIcons'
import { ToolCallContent } from './ToolCallContent'
import CloudObjectsCard from './tools/CloudObjectsCard.tsx'
@@ -539,6 +541,7 @@ function ToolCallLabel({
align="center"
wrap="wrap"
>
+ {toolName && }
+ ) : (
+
+ ))
const label = customLabel ?? (
- ) : undefined)
- }
+ leadingIcon={resolvedLeadingIcon}
/>
)
const accordionProps = {
@@ -141,6 +144,7 @@ export function SimpleToolCall({
subtitle={description ? undefined : subtitle}
runtime={toolRuntime}
isPending={isPending}
+ leadingIcon={resolvedLeadingIcon}
/>
)
return (
diff --git a/js/console/src/components/ai/chatbot/toolCallIcons.tsx b/js/console/src/components/ai/chatbot/toolCallIcons.tsx
new file mode 100644
index 0000000000..9d28e969a6
--- /dev/null
+++ b/js/console/src/components/ai/chatbot/toolCallIcons.tsx
@@ -0,0 +1,29 @@
+import { IconProps, PythonLogoIcon, ToolIcon } from '@pluralsh/design-system'
+import { ToolCallKind } from './toolCallDisplay'
+
+/** Icon next to a tool name when it is not a configured workbench tool. */
+export function ToolCallKindIcon({
+ kind,
+ size = 12,
+ ...props
+}: { kind: ToolCallKind } & IconProps) {
+ if (kind === 'python_sandbox') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/js/console/src/components/workbenches/tools/OauthTokenExchangeFormFields.tsx b/js/console/src/components/workbenches/tools/OauthTokenExchangeFormFields.tsx
index 513b4f0587..fcc0008ce3 100644
--- a/js/console/src/components/workbenches/tools/OauthTokenExchangeFormFields.tsx
+++ b/js/console/src/components/workbenches/tools/OauthTokenExchangeFormFields.tsx
@@ -167,7 +167,7 @@ export function OauthTokenExchangeFormFields({
([])
- const { textStreamMap, jobLevelThinking } = useWorkbenchJobStreams(
- jobId,
- !!data
- )
+ const { textStreamMap, latestThoughtMap, jobLevelThinking } =
+ useWorkbenchJobStreams(jobId, !!data)
const userPromptIndices = useMemo(() => {
const indices = [0] // 0 is initial user prompt in topContent
@@ -166,6 +164,7 @@ export function WorkbenchJobActivities({
workbenchId={workbenchId}
workbenchName={workbenchName}
textStream={textStreamMap[activity.id] ?? ''}
+ latestThought={latestThoughtMap[activity.id]}
/>
)
}}
diff --git a/js/console/src/components/workbenches/workbench/job/WorkbenchJobActivity.tsx b/js/console/src/components/workbenches/workbench/job/WorkbenchJobActivity.tsx
index d983c2d2b3..3c7c205e26 100644
--- a/js/console/src/components/workbenches/workbench/job/WorkbenchJobActivity.tsx
+++ b/js/console/src/components/workbenches/workbench/job/WorkbenchJobActivity.tsx
@@ -25,8 +25,10 @@ import {
import {
getSearchQuery,
humanizeToolName,
+ resolveToolCallKind,
toolCallGroupHeader,
} from 'components/ai/chatbot/toolCallDisplay'
+import { ToolCallKindIcon } from 'components/ai/chatbot/toolCallIcons'
import { PreviewablePanel } from 'components/ai/chatbot/ToolCallContent'
import {
getWorkbenchToolLabel,
@@ -77,6 +79,7 @@ export function WorkbenchJobActivity({
isOpen,
activity,
textStream,
+ latestThought,
jobId,
workbenchId,
workbenchName,
@@ -84,6 +87,7 @@ export function WorkbenchJobActivity({
isOpen: boolean
activity: WorkbenchJobActivityFragment
textStream: Nullable
+ latestThought?: Nullable
jobId: string
workbenchId: string
workbenchName: string
@@ -227,6 +231,9 @@ export function WorkbenchJobActivity({
subagent
{trailingIcons}
+ {isRunning && !agentRun && latestThought && (
+
+ )}
- ) : undefined
+ const toolIcon = thoughtToolIcon({ tool, toolName, toolArgs })
return (
+ {thoughtToolIcon({ tool, toolName, toolArgs })}
+
+ {title}
+
+
+ )
+}
+
+const ActivityLatestToolSC = styled.span(({ theme }) => ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: theme.spacing.xxsmall,
+ minWidth: 0,
+ maxWidth: '40ch',
+ flex: '0 1 auto',
+}))
+
+function thoughtToolIcon({
+ tool,
+ toolName,
+ toolArgs,
+}: {
+ tool?: Nullable
+ toolName?: Nullable
+ toolArgs?: WorkbenchJobThoughtFragment['toolArgs']
+}) {
+ if (tool) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
function compactWorkbenchToolCallTitle(
toolName: Nullable,
tool: WorkbenchToolTinyFragment
diff --git a/js/console/src/components/workbenches/workbench/job/useWorkbenchJobStreams.tsx b/js/console/src/components/workbenches/workbench/job/useWorkbenchJobStreams.tsx
index dafe1c9805..269a531757 100644
--- a/js/console/src/components/workbenches/workbench/job/useWorkbenchJobStreams.tsx
+++ b/js/console/src/components/workbenches/workbench/job/useWorkbenchJobStreams.tsx
@@ -30,11 +30,14 @@ import { produce } from 'immer'
// keyed by activity id, 'none' value puts it at the top level of the job
type WorkbenchJobTextStreamMap = Record
+// keyed by activity id
+type WorkbenchJobLatestThoughtMap = Record
+
export type WorkbenchJobLevelThinkingItem = WorkbenchJobProgressFragment & {
localKey: number
}
-// only returns a map of the ephemeral text streams, others subs are added to Apollo cache
+// only returns maps of the ephemeral streams, others subs are added to Apollo cache
export function useWorkbenchJobStreams(
jobId: Nullable,
activityQueryLoaded: boolean
@@ -43,6 +46,8 @@ export function useWorkbenchJobStreams(
const [textStreamMap, setTextStreamMap] = useState(
{}
)
+ const [latestThoughtMap, setLatestThoughtMap] =
+ useState({})
const [jobLevelThinking, setJobLevelThinking] = useState<
WorkbenchJobLevelThinkingItem[]
>([])
@@ -91,8 +96,10 @@ export function useWorkbenchJobStreams(
ignoreResults: true,
onData: ({ data: { data } }) => {
const thought = data?.workbenchJobThoughtDelta?.payload
- if (!thought?.activity?.id) return
+ const activityId = thought?.activity?.id
+ if (!thought || !activityId) return
appendThoughtToActivityCache(client.cache, thought)
+ setLatestThoughtMap((prev) => ({ ...prev, [activityId]: thought }))
},
})
useWorkbenchJobActivityDeltaSubscription({
@@ -122,7 +129,7 @@ export function useWorkbenchJobStreams(
},
})
- return { textStreamMap, jobLevelThinking }
+ return { textStreamMap, latestThoughtMap, jobLevelThinking }
}
export const appendActivityToCache = (
diff --git a/lib/console/ai/provider/token_exchange/assertion.ex b/lib/console/ai/provider/token_exchange/assertion.ex
index 83bece9562..669ad29597 100644
--- a/lib/console/ai/provider/token_exchange/assertion.ex
+++ b/lib/console/ai/provider/token_exchange/assertion.ex
@@ -17,8 +17,9 @@ defmodule Console.AI.Provider.TokenExchange.Assertion do
def token_config(), do: default_claims(default_exp: @ttl)
+ # some IdPs (eg ADFS) reject kid-only assertions and require x5t, the base64url SHA-1 cert thumbprint
defp signer(private_key, key_id) when is_binary(key_id) and key_id != "",
- do: Joken.Signer.create("RS256", %{"pem" => private_key}, %{"kid" => key_id})
+ do: Joken.Signer.create("RS256", %{"pem" => private_key}, %{"kid" => key_id, "x5t" => key_id})
defp signer(private_key, _),
do: Joken.Signer.create("RS256", %{"pem" => private_key})
diff --git a/lib/console/ai/tools/workbench/integration/docker/search_tags.ex b/lib/console/ai/tools/workbench/integration/docker/search_tags.ex
index 2218e32c99..d93a1900e2 100644
--- a/lib/console/ai/tools/workbench/integration/docker/search_tags.ex
+++ b/lib/console/ai/tools/workbench/integration/docker/search_tags.ex
@@ -11,7 +11,8 @@ defmodule Console.AI.Tools.Workbench.Integration.Docker.SearchTags do
field :tool, :map, virtual: true
field :repository_slug, :string
field :query, :string
- field :limit, :integer, default: 100
+ field :page_size, :integer, default: 100
+ field :cursor, :string
end
@json_schema Console.priv_file!("tools/workbench/integration/docker/search_tags.json") |> Jason.decode!()
@@ -19,25 +20,32 @@ defmodule Console.AI.Tools.Workbench.Integration.Docker.SearchTags do
def name(%__MODULE__{tool: %WorkbenchTool{name: name}}), do: "docker_#{name}_search_tags"
def description(%__MODULE__{tool: %WorkbenchTool{name: name}}),
- do: "Search tags in a Docker/OCI repository via #{name}."
+ do: "Search tags in a Docker/OCI repository via #{name}, one page at a time (paginate with next_cursor)."
def json_schema(%__MODULE__{}), do: @json_schema
def changeset(model, attrs) do
model
- |> cast(attrs, [:repository_slug, :query, :limit])
+ |> cast(attrs, [:repository_slug, :query, :page_size, :cursor])
|> validate_required([:repository_slug])
- |> validate_number(:limit, greater_than: 0, less_than_or_equal_to: 1000)
+ |> validate_number(:page_size, greater_than: 0, less_than_or_equal_to: 1000)
end
- def implement(%__MODULE__{tool: tool, repository_slug: repository_slug, query: query, limit: limit}) do
+ def implement(%__MODULE__{tool: tool, repository_slug: repository_slug} = args) do
with {:ok, client} <- Client.build(tool, repository_slug),
- {:ok, %OCI.Tags{tags: tags, name: name}} <-
- OCI.Client.tags(client, tag_filter(query), "", %OCI.Tags{}, limit || 100) do
- Jason.encode(%{repository: name, tags: tags})
+ {:ok, %{name: name, tags: tags, next_cursor: next}} <-
+ OCI.Client.tags_page(client,
+ page_size: args.page_size || 100,
+ cursor: cursor(args.cursor),
+ filter: tag_filter(args.query)
+ ) do
+ Jason.encode(%{repository: name, tags: tags, next_cursor: next})
end
end
+ defp cursor(c) when c in [nil, ""], do: nil
+ defp cursor(c), do: c
+
defp tag_filter(query) when query in [nil, ""], do: fn _ -> true end
defp tag_filter(query) when is_binary(query) do
diff --git a/lib/console/ai/workbench/heartbeat.ex b/lib/console/ai/workbench/heartbeat.ex
index 31ca6936b4..3854cabcc0 100644
--- a/lib/console/ai/workbench/heartbeat.ex
+++ b/lib/console/ai/workbench/heartbeat.ex
@@ -92,7 +92,11 @@ defmodule Console.AI.Workbench.Heartbeat do
end
end
- def terminate({:shutdown, :cancel}, %State{job: job, usage: usage}), do: Workbenches.save_usage(job, usage)
+ def terminate({:shutdown, :cancel}, %State{job: job, usage: usage}) do
+ with {:ok, _} <- Workbenches.cancel_job_subagents(job) do
+ Workbenches.save_usage(job, usage)
+ end
+ end
def terminate(:normal, %State{job: job, usage: usage}), do: Workbenches.save_usage(job, usage)
def terminate(:shutdown, %State{job: job, usage: usage}), do: Workbenches.pause_job(job, usage)
def terminate(:timeout, %State{job: job, usage: usage}),
diff --git a/lib/console/ai/workbench/skills.ex b/lib/console/ai/workbench/skills.ex
index 725f1d4a0b..8c58e6fa01 100644
--- a/lib/console/ai/workbench/skills.ex
+++ b/lib/console/ai/workbench/skills.ex
@@ -65,9 +65,9 @@ defmodule Console.AI.Workbench.Skills do
end
end
- def skill_file(name, %Workbench{repository: %GitRepository{} = r, skills: %Workbench.Skills{ref: ref, files: [_ | _] = files}}) do
+ def skill_file(name, %Workbench{repository: %GitRepository{} = r, skills: %Workbench.Skills{ref: ref, files: files}}) do
with {:ok, contents} <- Git.fetch(r, ref) do
- Enum.filter(contents, fn {k, _} -> k in files end)
+ Enum.filter(contents, &skill_file?(&1, files))
|> Enum.find(fn {file, skill} ->
case parse_skill(file, skill) do
{:ok, %Skill{name: ^name}} -> true
diff --git a/lib/console/clients/oci/client.ex b/lib/console/clients/oci/client.ex
index f5a033a37c..286b5869fd 100644
--- a/lib/console/clients/oci/client.ex
+++ b/lib/console/clients/oci/client.ex
@@ -1,5 +1,6 @@
defmodule Console.OCI.Client do
alias Console.OCI.{Tags, Manifest, Repositories}
+ alias Console.Utils.HTTP
require Logger
@manifest_types Enum.join(
@@ -28,7 +29,7 @@ defmodule Console.OCI.Client do
def with_proxy(%__MODULE__{} = client, nil), do: client
def with_proxy(%__MODULE__{client: req} = client, proxy) do
- %{client | client: put_proxy(req, proxy), proxy: proxy}
+ %{client | client: put_proxy(req, proxy, to_string(req.options[:base_url])), proxy: proxy}
end
def with_token(%{client: req} = client, token) do
@@ -44,21 +45,45 @@ defmodule Console.OCI.Client do
end
def tags(client, filter \\ fn _ -> true end, query \\ "", acc \\ %Tags{}, limit \\ nil) do
- case authed_get(client, "/v2/:repo/tags/list?n=1000#{query}") do
- {:ok, %Req.Response{status: status, body: body, headers: %{"link" => _}}} when status in 200..299 ->
+ case authed_get_client(client, "/v2/:repo/tags/list?n=1000#{query}") do
+ {{:ok, %Req.Response{status: status, headers: %{"link" => link}} = resp}, client} when status in 200..299 ->
+ body = json_body(resp)
new = Tags.new(body, filter)
merged = merge_tags(acc, new)
cond do
limit_reached?(merged.tags, limit) -> {:ok, trim_tags(merged, limit)}
is_nil(last(body["tags"])) -> {:ok, merged}
- true -> tags(client, filter, "&last=#{last(body["tags"])}", merged, limit)
+ true -> tags(client, filter, next_query(link, last(body["tags"])), merged, limit)
end
- {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
- {:ok, merge_tags(acc, Tags.new(body, filter)) |> trim_tags(limit)}
+ {{:ok, %Req.Response{status: status} = resp}, _} when status in 200..299 ->
+ {:ok, merge_tags(acc, Tags.new(json_body(resp), filter)) |> trim_tags(limit)}
+ {err, _} -> handle_error(err)
+ end
+ end
+
+ @doc """
+ Fetches a single page of tags. `next_cursor` is an opaque registry cursor to pass back
+ as `:cursor` to fetch the following page, and is nil once the listing is exhausted.
+ """
+ @spec tags_page(%__MODULE__{}, keyword) :: {:ok, %{name: binary | nil, tags: [binary], next_cursor: binary | nil}} | {:error, term}
+ def tags_page(client, opts \\ []) do
+ page_size = Keyword.get(opts, :page_size, 100)
+ filter = Keyword.get(opts, :filter, fn _ -> true end)
+ query = cursor_query(Keyword.get(opts, :cursor))
+
+ case authed_get(client, "/v2/:repo/tags/list?n=#{page_size}#{query}") do
+ {:ok, %Req.Response{status: status} = resp} when status in 200..299 ->
+ body = json_body(resp)
+ %Tags{name: name, tags: tags} = Tags.new(body, filter)
+ {:ok, %{name: name, tags: tags, next_cursor: page_cursor(resp, body["tags"])}}
err -> handle_error(err)
end
end
+ defp page_cursor(%Req.Response{headers: %{"link" => link}}, [_ | _] = tags),
+ do: next_cursor(link, last(tags))
+ defp page_cursor(_, _), do: nil
+
defp merge_tags(old, new), do: put_in(new.tags, Enum.concat(new.tags, old.tags))
defp trim_tags(tags, nil), do: tags
defp trim_tags(%Tags{} = tags, limit) when is_integer(limit),
@@ -67,18 +92,19 @@ defmodule Console.OCI.Client do
defp trim_tags(tags, _), do: tags
def repositories(client, filter \\ fn _ -> true end, query \\ "", acc \\ %Repositories{}, limit \\ nil) do
- case authed_get(client, "/v2/_catalog?n=1000#{query}") do
- {:ok, %Req.Response{status: status, body: body, headers: %{"link" => _}}} when status in 200..299 ->
+ case authed_get_client(client, "/v2/_catalog?n=1000#{query}") do
+ {{:ok, %Req.Response{status: status, headers: %{"link" => link}} = resp}, client} when status in 200..299 ->
+ body = json_body(resp)
new = Repositories.new(body, filter)
merged = merge_repositories(acc, new)
cond do
limit_reached?(merged.repositories, limit) -> {:ok, trim_repositories(merged, limit)}
is_nil(last(body["repositories"])) -> {:ok, merged}
- true -> repositories(client, filter, "&last=#{last(body["repositories"])}", merged, limit)
+ true -> repositories(client, filter, next_query(link, last(body["repositories"])), merged, limit)
end
- {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
- {:ok, merge_repositories(acc, Repositories.new(body, filter)) |> trim_repositories(limit)}
- err -> handle_error(err)
+ {{:ok, %Req.Response{status: status} = resp}, _} when status in 200..299 ->
+ {:ok, merge_repositories(acc, Repositories.new(json_body(resp), filter)) |> trim_repositories(limit)}
+ {err, _} -> handle_error(err)
end
end
@@ -96,6 +122,33 @@ defmodule Console.OCI.Client do
defp last([_ | _] = items), do: List.last(items)
defp last(_), do: nil
+ # some registries (eg public.ecr.aws) serve json with a text/plain content type, so req won't decode it
+ defp json_body(%Req.Response{body: body}) when is_binary(body) do
+ case Jason.decode(body) do
+ {:ok, %{} = decoded} -> decoded
+ _ -> %{}
+ end
+ end
+ defp json_body(%Req.Response{body: %{} = body}), do: body
+ defp json_body(_), do: %{}
+
+ defp next_query(link, fallback), do: cursor_query(next_cursor(link, fallback))
+
+ defp cursor_query(nil), do: ""
+ defp cursor_query(cursor), do: "&" <> URI.encode_query(%{"last" => cursor})
+
+ # the `last` pagination cursor is opaque for some registries (eg public.ecr.aws), so prefer the one in the link header
+ defp next_cursor(link, fallback) do
+ with [value | _] <- List.wrap(link),
+ [_, path] <- Regex.run(~r/<([^>]+)>/, value),
+ %URI{query: q} when is_binary(q) <- URI.parse(path),
+ %{"last" => last} <- URI.decode_query(q) do
+ last
+ else
+ _ -> fallback
+ end
+ end
+
def manifest(%{client: req} = client, tag) do
req = Req.Request.put_header(req, "accept", @manifest_types)
@@ -117,8 +170,9 @@ defmodule Console.OCI.Client do
end
defp dkr_client(h, repo, proxy) do
- Req.new(base_url: "https://#{h}", retry: false, redirect: true)
- |> put_proxy(proxy)
+ base_url = "https://#{h}"
+ Req.new(base_url: base_url, retry: &retry_rate_limited/2, max_retries: 3, redirect: true)
+ |> put_proxy(proxy, base_url)
|> Req.Request.register_options([:dkr_repo])
|> Req.Request.merge_options(dkr_repo: repo)
|> Req.Request.append_request_steps(dkr_repo: fn %{options: %{dkr_repo: repo}} = req ->
@@ -130,15 +184,27 @@ defmodule Console.OCI.Client do
|> Req.Request.prepend_response_steps(dkr_repo: & &1)
end
- defp authed_get(%__MODULE__{client: req, auth_client: auth} = client, url, opts \\ []) do
+ # public.ecr.aws aggressively rate limits anonymous pulls
+ defp retry_rate_limited(_, %Req.Response{status: 429}), do: true
+ defp retry_rate_limited(_, _), do: false
+
+ defp authed_get(client, url, opts \\ []) do
+ {resp, _} = authed_get_client(client, url, opts)
+ resp
+ end
+
+ # returns the (possibly re-authenticated) client alongside the response so paginated calls can reuse the token
+ defp authed_get_client(%__MODULE__{client: req, auth_client: auth} = client, url, opts \\ []) do
{no_recurse, opts} = Keyword.pop(opts, :no_recurse, false)
case {Req.get(req, add_opts(req, [url: url], opts)), no_recurse} do
- {{:ok, %Req.Response{status: status}} = resp, true} when status in 200..299 -> resp
+ {{:ok, %Req.Response{status: status}} = resp, true} when status in 200..299 -> {resp, client}
{{:ok, %Req.Response{status: 401, headers: %{"www-authenticate" => [www_auth | _]}}}, false} ->
- with {:ok, client} <- authenticate_challenge(client, url, www_auth, auth),
- do: authed_get(client, url, Keyword.put(opts, :no_recurse, true))
- {_, true} -> {:error, "could not resolve authentication for #{url}"}
- {err, _} -> err
+ case authenticate_challenge(client, url, www_auth, auth) do
+ {:ok, client} -> authed_get_client(client, url, Keyword.put(opts, :no_recurse, true))
+ err -> {err, client}
+ end
+ {{:ok, %Req.Response{status: 401}}, true} -> {{:error, "could not resolve authentication for #{url}"}, client}
+ {res, _} -> {res, client}
end
end
@@ -188,33 +254,14 @@ defmodule Console.OCI.Client do
defp auth_client(:empty, proxy, url), do: Req.new(retry: false) |> put_proxy(proxy, url)
defp auth_client({u, p}, proxy, url), do: Req.new(auth: {:basic, "#{u}:#{p}"}, retry: false) |> put_proxy(proxy, url)
- defp put_proxy(req, proxy, url \\ nil)
- defp put_proxy(req, %{url: url} = proxy, request_url) when is_binary(url) do
- case no_proxy?(proxy, request_url || req.url) do
- true -> req
- false -> Req.Request.merge_options(req, proxy: url)
+ defp put_proxy(%Req.Request{options: options} = req, proxy, url) do
+ case HTTP.proxy_options(proxy, url) do
+ [] -> req
+ [connect_options: connect] ->
+ existing = Map.get(options, :connect_options, [])
+ Req.Request.merge_options(req, connect_options: Keyword.merge(existing, connect))
end
end
- defp put_proxy(req, _, _), do: req
-
- defp no_proxy?(%{noproxy: noproxy}, url) when is_binary(noproxy) and byte_size(noproxy) > 0 do
- host = url_host(url)
- noproxy
- |> String.split(",", trim: true)
- |> Enum.map(&String.trim/1)
- |> Enum.any?(&matches_no_proxy?(host, &1))
- end
- defp no_proxy?(_, _), do: false
-
- defp url_host(%URI{host: host}), do: host
- defp url_host(url) when is_binary(url), do: URI.parse(url).host
- defp url_host(_), do: nil
-
- defp matches_no_proxy?(host, pattern) when is_binary(host) and is_binary(pattern) do
- pattern = String.trim_leading(pattern, ".")
- host == pattern || String.ends_with?(host, ".#{pattern}")
- end
- defp matches_no_proxy?(_, _), do: false
defp parse_uri(uri) do
case URI.parse(uri) do
diff --git a/lib/console/deployments/workbenches.ex b/lib/console/deployments/workbenches.ex
index feb6213db5..0601236a92 100644
--- a/lib/console/deployments/workbenches.ex
+++ b/lib/console/deployments/workbenches.ex
@@ -28,6 +28,7 @@ defmodule Console.Deployments.Workbenches do
WorkbenchPolicy,
WorkbenchJobActivityAgentRun,
WorkbenchJobThought,
+ AgentRun,
Monitor,
PullRequest,
FlowWorkbench,
@@ -945,6 +946,7 @@ defmodule Console.Deployments.Workbenches do
|> allow(user, :edit)
|> when_ok(:update)
end)
+ |> add_operation(:subagents, fn %{job: job} -> cancel_job_subagents(job.id) end)
|> add_operation(:heartbeat, fn %{job: job} ->
Console.AI.Workbench.Router.stop(job)
{:ok, job}
@@ -1004,13 +1006,32 @@ defmodule Console.Deployments.Workbenches do
|> Repo.update()
end)
|> add_operation(:activities, fn _ ->
- WorkbenchJobActivity.for_workbench_job(job.id)
+ cancel_job_subagents(job.id)
+ end)
+ |> execute(extract: :job)
+ |> notify(:update)
+ end
+
+ @doc """
+ Cancels active subagent activities and their running agent runs.
+ """
+ @spec cancel_job_subagents(WorkbenchJob.t() | binary) :: {:ok, map} | {:error, any}
+ def cancel_job_subagents(%WorkbenchJob{id: id}), do: cancel_job_subagents(id)
+ def cancel_job_subagents(id) when is_binary(id) do
+ start_transaction()
+ |> add_operation(:activities, fn _ ->
+ WorkbenchJobActivity.for_workbench_job(id)
|> WorkbenchJobActivity.for_status(:running)
|> Repo.update_all(set: [status: :cancelled])
|> ok()
end)
- |> execute(extract: :job)
- |> notify(:update)
+ |> add_operation(:agent_runs, fn _ ->
+ AgentRun.for_workbench_job(id)
+ |> AgentRun.for_status(:running)
+ |> Repo.update_all(set: [status: :cancelled])
+ |> ok()
+ end)
+ |> execute()
end
@doc """
@@ -1593,6 +1614,7 @@ defmodule Console.Deployments.Workbenches do
})
|> Repo.update()
end)
+ |> add_operation(:subagents, fn _ -> cancel_job_subagents(job.id) end)
|> add_operation(:budget, fn _ -> update_budget(job.workbench_id, usage) end)
|> execute(extract: :job)
|> notify(:update)
diff --git a/lib/console/otel/exporter.ex b/lib/console/otel/exporter.ex
index 793578a963..4f91799d32 100644
--- a/lib/console/otel/exporter.ex
+++ b/lib/console/otel/exporter.ex
@@ -44,7 +44,7 @@ defmodule Console.Otel.Exporter do
"resource" => %{
"attributes" => [
%{"key" => "service.name", "value" => %{"stringValue" => "plural-console"}},
- %{"key" => "service.version", "value" => %{"stringValue" => Console.conf(:version)}}
+ %{"key" => "service.version", "value" => %{"stringValue" => Console.conf(:git_commit) || "unknown"}}
]
},
"scopeMetrics" => [
diff --git a/lib/console/schema/agent_run.ex b/lib/console/schema/agent_run.ex
index 08dd601d29..218d7b3929 100644
--- a/lib/console/schema/agent_run.ex
+++ b/lib/console/schema/agent_run.ex
@@ -11,7 +11,8 @@ defmodule Console.Schema.AgentRun do
AgentSession,
AgentRunUpload,
AIUsage,
- WorkbenchJobActivityAgentRun
+ WorkbenchJobActivityAgentRun,
+ WorkbenchJobActivity
}
@expiry 14
@@ -101,6 +102,16 @@ defmodule Console.Schema.AgentRun do
from(ar in query, where: ar.status == ^status)
end
+ def for_workbench_job(query \\ __MODULE__, job_id) do
+ from(ar in query,
+ join: association in WorkbenchJobActivityAgentRun,
+ on: association.agent_run_id == ar.id,
+ join: activity in WorkbenchJobActivity,
+ on: activity.id == association.workbench_job_activity_id,
+ where: activity.workbench_job_id == ^job_id
+ )
+ end
+
def expired(query \\ __MODULE__) do
expired = Timex.now() |> Timex.shift(days: -@expiry)
from(ar in query, where: ar.inserted_at < ^expired)
diff --git a/lib/console/schema/workbench.ex b/lib/console/schema/workbench.ex
index 4e25f17002..948cb8d62b 100644
--- a/lib/console/schema/workbench.ex
+++ b/lib/console/schema/workbench.ex
@@ -248,7 +248,7 @@ defmodule Console.Schema.Workbench do
model
|> cast(attrs, ~w(files)a)
|> cast_embed(:ref)
- |> validate_required([:ref, :files])
+ |> validate_required([:ref])
end
def configuration_changeset(model, attrs \\ %{}) do
diff --git a/lib/console/utils/http.ex b/lib/console/utils/http.ex
index 8b56ba58c4..086269bc41 100644
--- a/lib/console/utils/http.ex
+++ b/lib/console/utils/http.ex
@@ -68,16 +68,52 @@ defmodule Console.Utils.HTTP do
Keyword.merge(@client_defaults, provider_options(legacy_key, req_key))
end
+ @doc """
+ Builds `Req` proxy options for a request to `url` from a proxy config shaped
+ like `%{url: proxy_url, noproxy: "host1,.domain2"}`. Returns no options when no
+ proxy is configured or the request host matches a `noproxy` entry.
+ """
+ @spec proxy_options(map | nil, binary) :: keyword
+ def proxy_options(%{url: proxy} = config, url) when is_binary(proxy) and proxy != "" do
+ case no_proxy?(config, url) do
+ true -> []
+ false -> req_options(proxy: proxy)
+ end
+ end
+ def proxy_options(_, _), do: []
+
+ defp no_proxy?(%{noproxy: noproxy}, url) when is_binary(noproxy) and is_binary(url) do
+ host = URI.parse(url).host
+ noproxy
+ |> String.split(",", trim: true)
+ |> Enum.map(&String.trim/1)
+ |> Enum.any?(&matches_no_proxy?(host, &1))
+ end
+ defp no_proxy?(_, _), do: false
+
+ defp matches_no_proxy?(host, pattern) when is_binary(host) and pattern != "" do
+ pattern = String.trim_leading(pattern, ".")
+ host == pattern || String.ends_with?(host, ".#{pattern}")
+ end
+ defp matches_no_proxy?(_, _), do: false
+
defp merge_connect(opts, connect) do
Keyword.update(opts, :connect_options, connect, &Keyword.merge(&1, connect))
end
defp parse_proxy(url) do
- uri = URI.parse(url)
+ uri = URI.parse(with_scheme(String.trim(url)))
scheme = if uri.scheme == "https", do: :https, else: :http
{scheme, uri.host, uri.port || default_port(scheme), []}
end
+ defp with_scheme(url) do
+ case String.contains?(url, "://") do
+ true -> url
+ false -> "http://#{url}"
+ end
+ end
+
defp default_port(:https), do: 443
defp default_port(_), do: 80
end
diff --git a/mix.exs b/mix.exs
index 28639f09ca..d73eb6a4d9 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,46 +1,11 @@
defmodule Console.MixProject do
use Mix.Project
- defp version do
- case :file.consult(~c"hex_metadata.config") do
- {:ok, data} ->
- {"version", version} = List.keyfind(data, "version", 0)
- version
- _ ->
- version =
- case System.cmd("git", ~w[describe --dirty=+dirty]) do
- {"go/" <> _, 0} -> "0.0.0"
- {version, 0} ->
- String.trim_leading(String.trim(version), "v")
-
- {_, code} ->
- Mix.shell().error("Git exited with code #{code}, falling back to 0.0.0")
-
- "0.0.0"
- end
-
- case Version.parse(version) do
- {:ok, %Version{pre: ["pre" <> _ | _]} = version} ->
- to_string(version)
-
- {:ok, %Version{pre: []} = version} ->
- to_string(version)
-
- {:ok, %Version{patch: patch, pre: pre} = version} ->
- to_string(%{version | patch: patch + 1, pre: ["dev" | pre]})
-
- :error ->
- Mix.shell().error("Failed to parse #{version}, falling back to 0.0.0")
-
- "0.0.0"
- end
- end
- end
-
def project do
[
app: :console,
- version: version(),
+ # deployed versions come from CONSOLE_VERSION (chart appVersion) and GIT_COMMIT at runtime
+ version: "0.1.0",
build_path: "_build",
config_path: "config/config.exs",
deps_path: "deps",
diff --git a/mix.lock b/mix.lock
index a38c595bdd..313de2b629 100644
--- a/mix.lock
+++ b/mix.lock
@@ -12,7 +12,7 @@
"apq": {:hex, :apq, "2.0.1", "955cd8e28ff8213097d54e171764141cb76c2d7977027b3e6697af2bef4c8e29", [:mix], [{:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}], "hexpm", "22d9bb29d61901770275fe2255c3da8bf31552a503ba55558dbbad53a371012c"},
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"bandit": {:hex, :bandit, "1.12.4", "10bbab488edf8162318d736c19c5837077b8fca2bf5d95b07b33830387124f62", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "84513318c5752a2a8017664450f889b47fae5d53d64698ddf1e4fb09a7449e8d"},
- "botanist": {:git, "https://github.com/michaeljguarino/botanist.git", "10244d96a88c3a5aa4f77f343bbb59643c623986", [branch: "ecto3"]},
+ "botanist": {:git, "https://github.com/michaeljguarino/botanist.git", "3d70f912ad47d696ec095a4cb28e60a552ea91f8", [branch: "ecto3"]},
"bourne": {:hex, :bourne, "1.1.0", "60a8bb3d5ad533a3a18257de9304e70e6bb2f4d7f354a529ef3f47c327c2dd97", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "81a389611ad067dc305d5ba4674280ea2b99ef012bca5e7de48357545edc05bc"},
"briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"},
"caramelize": {:hex, :caramelize, "1.2.1", "f67b936ba73112a61f6f4940157f150a5fadf16582faec7ca6a19e3228ebf9a9", [:mix], [{:recase, "~> 0.4", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "005d1546305527b5c1dd9c92852f3ee766c5268045d54a2b56df438ec65cf01b"},
diff --git a/priv/agent-service-chart.tgz b/priv/agent-service-chart.tgz
index 98b929fb42..4291e57616 100644
Binary files a/priv/agent-service-chart.tgz and b/priv/agent-service-chart.tgz differ
diff --git a/priv/prompts/workbench/job.md.eex b/priv/prompts/workbench/job.md.eex
index 35d51f2411..f36c65b83b 100644
--- a/priv/prompts/workbench/job.md.eex
+++ b/priv/prompts/workbench/job.md.eex
@@ -6,8 +6,8 @@ the task. You'll be given the following:
* the various capabilities of the tools at your disposal, this could be querying observability systems, introspecting infrastructure configuration, and more.
* interactions with additional tools like task management software or internal apis.
* in addition you'll have some set of the following subagents to delegate work to, any of which could be useful to accomplish your task:
- 1. an infrastructure search agent to probe infrastructure state and configuration. This will be useful for querying kubernetes api state and terraform state, including inspecting the installed API and CRD schemas directly from a cluster, and Docker/OCI registry metadata (tags, manifests) when those tools are configured. It is **not** for querying observability systems like log stores or metrics stores.
- 2. a coding agent to either analyze or modify code, generating a pull request<%= if @review do %>, or to review an existing pull request<% end %>
+ 1. an infrastructure search agent to probe infrastructure state and configuration. This will be useful for querying kubernetes api state and terraform state, including inspecting the installed API and CRD schemas directly from a cluster, and Docker/OCI registry metadata (tags, manifests) when those tools are configured. It is **not** for querying observability systems like log stores or metrics stores, and it **cannot** read source code repositories.
+ 2. a coding agent to either analyze or modify code, generating a pull request<%= if @review do %>, or to review an existing pull request<% end %>. This is the only subagent that can read files in a git repository
3. an observability agent, to probe observability systems and analyze the outputs
4. a monitoring agent, specifically to inspect, create, update, reinterpret, or delete persistent Plural dashboards and monitors. Use it whenever the requested result should remain available for repeat use, future monitoring, or alerting. Use observability for investigation and monitoring for changing dashboard or alerting configuration.
5. an integration tooling agent, to interact with business tools via apis and known interfaces. Useful for things like reporting or interacting with internal systems if present, usually task tracking tools, knowledge bases or internal compliance software that's not SRE related. **If you want to fetch observability-related data, use the observability agent instead.**
@@ -23,6 +23,10 @@ Basic guardrails for using these subagents:
* Delegate persistent dashboard and monitor creation, updates, imports, and deletion to the monitoring agent. This includes any dashboard intended for repeat use, reopening later, ongoing monitoring, or sharing as monitoring configuration. It can query observability data itself to validate the configuration it manages.
* Use the canvas agent for an ephemeral visual explanation attached to this specific job. Default to canvas for non-trivial investigations with informative metrics, logs, traces, multiple evidence sources, or conclusions that benefit from visual organization. If the user says "create a dashboard" and does not explicitly ask for a one-off explanation of the current job, prefer the monitoring agent. Never substitute a canvas for a persistent dashboard or monitor.
* When you're ready to inspect code, leverage the coding agent to either analyze or modify code, generating a pull request in write mode.
+* Distinguish live GitOps state from source code:
+ * **Live GitOps state** (infrastructure agent): Plural's view of what is deployed, including which Plural Service or Stack owns a resource, the repository/folder/ref it points at, its rendered manifests, terraform state, and live Kubernetes objects.
+ * **Source code** (coding agent): the contents of files in a git repository, including application code, raw Helm charts, values files, Kustomize overlays, terraform modules, Dockerfiles, CI config, and git history (log, blame, diffs, bisect).
+ * Never ask the infrastructure agent to browse, search, grep, or read files in a repository, even a GitOps/IaC repository. At most it can tell you *which* repo and path to look in, and you then hand that to the coding agent in analyze mode.
<%= if @review do %>
* Pull request review is in scope for this job. When the task is to review a PR, launch the coding subagent and instruct it to use review mode with the PR URL and the PR's head branch (the branch containing the changes). Do not use analyze or write mode to inspect an existing PR.
* The coding agent owns the entire review, including publishing its summary and inline findings. Do not delegate any part of pull request review or review publication to the integration subagent or SCM integration tools.
@@ -165,7 +169,8 @@ Two agents can give useful information about the codebase defining the system:
b. Leverage skills, which users will provide and help with mapping these where Plural's native intelligence has gaps.
3. Any code change made should prefer fixing the root cause of the issue. If it's an application issue that could be turned off with a GitOps/IaC change, you should prefer fixing the application issue (or generate prs to do both).
4. `infrastructure` can both live introspect infra state in kubernetes and IaC defined IaaS services. But it can also be used to gather the gitops structure that defines that
- infra state. If you think a gitops change is necessary, use it to gather that intel, then feed the result into the coding agent to make whatever modification is necessary.
+ infra state, meaning which service/stack owns it and which repo, folder, and ref that service/stack is sourced from. It does **not** have repository access, so it cannot read the files at that location.
+ If you think a gitops change is necessary, or need to understand how something is written in git, use it to locate the owning repo/path, then feed the result into the coding agent to read or modify it.
broadly, any infrastructure configuration related information should be accessible via this subagent, including plural-related knowledge about infrastructure like agent health and cluster health scores.
5. You should not expect *any* subagent to have full context of your overall investigation. They run in their own thread and have their own context. *If you need them to utilize information you collected previously, specify it explicitly.*
6. You should generally gather all external context, as much as needed, before delegating to a coding subagent. It generally won't have the ability to do things like web search, or correlate external observability unless configured, so you should do that for it and pass everything needed that's been discovered.
diff --git a/priv/prompts/workbench/monitoring.md.eex b/priv/prompts/workbench/monitoring.md.eex
index 5740aa7240..e3b39602f8 100644
--- a/priv/prompts/workbench/monitoring.md.eex
+++ b/priv/prompts/workbench/monitoring.md.eex
@@ -13,6 +13,8 @@ This subagent is explicitly for dashboard and monitor management. Use it when th
Organize dashboards with section graphs when they contain multiple concerns or more than a few panels. Include each section and its direct child graphs in the same upsert batch, with `type: section` on the section and `section_id` on each child. Sections are collapsible one-level containers: never assign a section graph to another section. Layout coordinates are local to each section. Set a section's `options.collapsed` to `true` only when it should start collapsed; sections are expanded by default.
+Dashboard datasource inputs use `${name}` placeholders to reference dashboard input values, where `name` exactly matches an entry in `settings.inputs`. Always use the braced form, for example `${cluster}` and `${namespace}`. Bare Grafana-style variables such as `$cluster` and `$namespace` are not supported and will be passed to the observability provider without substitution. Apply the same rule when one dashboard input datasource depends on another input.
+
The dashboard viewer defaults to a 1h range. Prefer 1h when validating dashboard queries unless the user asks for another period. For dashboard metric data sources, do not hardcode a `step` unless the user explicitly requires one. The dashboard runtime derives a provider-compatible step from the viewer's selected range (1h, 2h, 6h, 1d, or 7d) to return a useful number of datapoints. A fixed step prevents that adaptation and can produce invalid or overly dense results.
Dashboard and monitor definitions are also valuable documentation for the system. Their descriptions, graph data sources, variable inputs, queries, thresholds, and current monitor states should inform further observability queries.
diff --git a/priv/tools/workbench/integration/docker/search_tags.json b/priv/tools/workbench/integration/docker/search_tags.json
index f2610a5282..1eb7ca1926 100644
--- a/priv/tools/workbench/integration/docker/search_tags.json
+++ b/priv/tools/workbench/integration/docker/search_tags.json
@@ -1,6 +1,6 @@
{
"type": "object",
- "description": "Search tags for a Docker/OCI repository slug.",
+ "description": "List one page of tags for a Docker/OCI repository slug. Registries return tags in their own order (not necessarily sorted by recency), so if the tag you need isn't on this page, call again with the returned next_cursor until next_cursor is null.",
"properties": {
"repository_slug": {
"type": "string",
@@ -8,14 +8,18 @@
},
"query": {
"type": "string",
- "description": "Optional case-insensitive substring used to filter tags in memory."
+ "description": "Optional case-insensitive substring used to filter the tags within the fetched page. Filtering happens after the page is fetched, so a page can return no matches while next_cursor is still set; keep paginating to search the rest of the repository."
},
- "limit": {
+ "page_size": {
"type": "integer",
"default": 100,
"minimum": 1,
"maximum": 1000,
- "description": "Maximum number of tags to return."
+ "description": "Number of tags to request from the registry for this page (before query filtering). Use larger pages when filtering with query."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "Opaque pagination cursor from a previous call's next_cursor. Omit to start from the first page."
}
},
"required": ["repository_slug"],
diff --git a/priv/tools/workbench/monitoring/dashboard_upsert.json b/priv/tools/workbench/monitoring/dashboard_upsert.json
index c761c5980a..7f04c0cc87 100644
--- a/priv/tools/workbench/monitoring/dashboard_upsert.json
+++ b/priv/tools/workbench/monitoring/dashboard_upsert.json
@@ -47,7 +47,7 @@
"tool": { "type": "string" },
"input": {
"type": "object",
- "description": "Arguments for the named data source. For metrics dashboards, omit step unless the user explicitly requires a fixed value; the runtime derives a provider-compatible step from the selected time range."
+ "description": "Arguments for the named data source. Reference dashboard inputs only with braced placeholders such as ${cluster} or ${namespace}; bare forms such as $cluster are not substituted. For metrics dashboards, omit step unless the user explicitly requires a fixed value; the runtime derives a provider-compatible step from the selected time range."
}
},
"required": ["type", "tool", "input"]
@@ -70,7 +70,10 @@
"items": {
"type": "object",
"properties": {
- "name": { "type": "string" },
+ "name": {
+ "type": "string",
+ "description": "Variable name referenced from datasource inputs using the exact braced form ${name}."
+ },
"label": { "type": "string" },
"description": { "type": "string" },
"type": {
@@ -85,7 +88,10 @@
"properties": {
"type": { "type": "string", "enum": ["logs", "metrics", "traces", "labels"] },
"tool": { "type": "string" },
- "input": { "type": "object" }
+ "input": {
+ "type": "object",
+ "description": "Arguments for populating this input. References to other dashboard inputs must use braced placeholders such as ${cluster}; bare forms such as $cluster are not substituted."
+ }
},
"required": ["type", "tool", "input"]
}
diff --git a/test/console/ai/provider/token_exchange_test.exs b/test/console/ai/provider/token_exchange_test.exs
index 956f5e4891..d6182970ec 100644
--- a/test/console/ai/provider/token_exchange_test.exs
+++ b/test/console/ai/provider/token_exchange_test.exs
@@ -247,6 +247,21 @@ defmodule Console.AI.Provider.TokenExchangeTest do
assert audience == @token_url <> "/"
assert is_binary(jti)
assert exp - iat == 300
+
+ assert {:ok, %{"alg" => "RS256", "kid" => "signing-key", "x5t" => "signing-key"}} =
+ Joken.peek_header(assertion)
+ end
+
+ test "omits kid and x5t headers without a key id" do
+ {:ok, private_key} = ExPublicKey.generate_key()
+ {:ok, private_key} = ExPublicKey.pem_encode(private_key)
+
+ assert {:ok, assertion} =
+ TokenExchange.Assertion.mint(@client_id, @token_url, private_key, nil)
+
+ assert {:ok, header} = Joken.peek_header(assertion)
+ refute Map.has_key?(header, "kid")
+ refute Map.has_key?(header, "x5t")
end
test "exchanges a signed assertion with resource and caches the token" do
diff --git a/test/console/ai/tools/workbench/integration/docker/tools_test.exs b/test/console/ai/tools/workbench/integration/docker/tools_test.exs
index d730dd55b0..f21c4e52d8 100644
--- a/test/console/ai/tools/workbench/integration/docker/tools_test.exs
+++ b/test/console/ai/tools/workbench/integration/docker/tools_test.exs
@@ -12,24 +12,75 @@ defmodule Console.AI.Tools.Workbench.Integration.Docker.ToolsTest do
alias Console.Schema.WorkbenchTool.Configuration.DockerConnection
@repository "library/nginx"
+ @ecr_repository "docker/library/nginx"
describe "search_tags/1" do
- test "searches public Docker Hub tags with a limit" do
+ test "searches a page of public Docker Hub tags" do
assert {:ok, json} =
SearchTags.implement(%SearchTags{
tool: docker_tool(),
repository_slug: @repository,
query: "alpine",
- limit: 3
+ page_size: 50
})
- assert %{"repository" => @repository, "tags" => tags} = Jason.decode!(json)
- assert length(tags) <= 3
+ assert %{"repository" => @repository, "tags" => tags, "next_cursor" => cursor} = Jason.decode!(json)
+ assert length(tags) <= 50
assert Enum.all?(tags, &String.contains?(&1, "alpine"))
+ assert is_binary(cursor)
end
+
+ test "paginates public Docker Hub tags with a cursor" do
+ assert_paginates(docker_tool(), @repository)
+ end
+
+ test "searches a page of public.ecr.aws tags" do
+ assert {:ok, json} =
+ SearchTags.implement(%SearchTags{
+ tool: ecr_public_tool(),
+ repository_slug: @ecr_repository,
+ query: "alpine",
+ page_size: 1000
+ })
+
+ assert %{"repository" => @ecr_repository, "tags" => [_ | _] = tags, "next_cursor" => cursor} =
+ Jason.decode!(json)
+ assert Enum.all?(tags, &String.contains?(&1, "alpine"))
+ assert is_binary(cursor)
+ end
+
+ test "paginates public.ecr.aws tags with a cursor" do
+ assert_paginates(ecr_public_tool(), @ecr_repository)
+ end
+ end
+
+ defp assert_paginates(tool, repository) do
+ search = %SearchTags{tool: tool, repository_slug: repository, page_size: 5}
+
+ assert {:ok, first} = SearchTags.implement(search)
+ assert %{"tags" => [_ | _] = page1, "next_cursor" => cursor} = Jason.decode!(first)
+ assert length(page1) == 5
+ assert is_binary(cursor)
+
+ assert {:ok, second} = SearchTags.implement(%{search | cursor: cursor})
+ assert %{"tags" => [_ | _] = page2} = Jason.decode!(second)
+ assert length(page2) == 5
+ assert MapSet.disjoint?(MapSet.new(page1), MapSet.new(page2))
end
describe "fetch_manifest/1" do
+ test "fetches the manifest for a public.ecr.aws tag" do
+ assert {:ok, json} =
+ FetchManifest.implement(%FetchManifest{
+ tool: ecr_public_tool(),
+ repository_slug: @ecr_repository,
+ tag: "latest"
+ })
+
+ assert %{"manifest" => manifest} = Jason.decode!(json)
+ assert manifest["schemaVersion"] == 2
+ end
+
test "fetches the manifest for a public Docker Hub tag" do
assert {:ok, json} =
FetchManifest.implement(%FetchManifest{
@@ -64,4 +115,16 @@ defmodule Console.AI.Tools.Workbench.Integration.Docker.ToolsTest do
}
}
end
+
+ defp ecr_public_tool do
+ %WorkbenchTool{
+ name: "ecr_public",
+ tool: :docker,
+ configuration: %Configuration{
+ docker: %DockerConnection{
+ url: "public.ecr.aws"
+ }
+ }
+ }
+ end
end
diff --git a/test/console/ai/workbench/heartbeat_test.exs b/test/console/ai/workbench/heartbeat_test.exs
index c8209957b9..cb5077add1 100644
--- a/test/console/ai/workbench/heartbeat_test.exs
+++ b/test/console/ai/workbench/heartbeat_test.exs
@@ -1,6 +1,7 @@
defmodule Console.AI.Workbench.HeartbeatTest do
use Console.DataCase, async: false
alias Console.AI.{ModelSelection, Workbench.Heartbeat}
+ alias Console.Deployments.Workbenches
alias Console.Schema.{Workbench, WorkbenchJob}
alias Console.Schema.Workbench.Budget
@@ -232,6 +233,15 @@ defmodule Console.AI.Workbench.HeartbeatTest do
)
job = insert(:workbench_job, status: :running, workbench: workbench)
+ activity = insert(:workbench_job_activity, workbench_job: job, status: :running)
+ running = insert(:agent_run, status: :running)
+ pending_approval = insert(:agent_run, status: :pending_approval)
+ babysitting = insert(:agent_run, status: :babysitting)
+
+ for run <- [running, pending_approval, babysitting] do
+ {:ok, _} = Workbenches.associate_agent_run(activity, run.id)
+ end
+
{:ok, pid} = Heartbeat.start_link(job)
Process.unlink(pid)
ref = Process.monitor(pid)
@@ -256,6 +266,10 @@ defmodule Console.AI.Workbench.HeartbeatTest do
assert_in_delta persisted_job.usage.output_cost, @usage.output_cost, 0.000_001
assert_in_delta persisted_job.usage.total_cost, @usage.total_cost, 0.000_001
assert Console.Repo.get!(Workbench, workbench.id).budget.last == 875
+ assert refetch(activity).status == :cancelled
+ assert refetch(running).status == :cancelled
+ assert refetch(pending_approval).status == :pending_approval
+ assert refetch(babysitting).status == :babysitting
end
test "terminates the linked engine process when cancelled" do
diff --git a/test/console/ai/workbench/skills_test.exs b/test/console/ai/workbench/skills_test.exs
index 6d392bd570..3aec45da10 100644
--- a/test/console/ai/workbench/skills_test.exs
+++ b/test/console/ai/workbench/skills_test.exs
@@ -1,8 +1,18 @@
defmodule Console.AI.Workbench.SkillsTest do
use ExUnit.Case, async: true
+ use Mimic
- alias Console.AI.Workbench.Skills
- alias Console.Schema.{Workbench, WorkbenchSkill}
+ alias Console.AI.Workbench.{Skill, Skills}
+ alias Console.Deployments.Git
+ alias Console.Schema.{GitRepository, Service, Workbench, WorkbenchSkill}
+
+ @skill """
+ ---
+ name: auto
+ description: detected from SKILL.md
+ ---
+ body
+ """
@example """
---
@@ -41,6 +51,60 @@ defmodule Console.AI.Workbench.SkillsTest do
| Production | `https://git.example.org/cluster-gitops` |
"""
+ describe "skills/1" do
+ test "loads SKILL.md files when the explicit file list is missing" do
+ {wb, repo, ref} = git_workbench(nil)
+
+ expect(Git, :fetch, fn ^repo, ^ref ->
+ {:ok, [
+ {"notes.md", "ignore me"},
+ {"SKILL.md", @skill},
+ {"cluster/SKILL.md", @skill}
+ ]}
+ end)
+
+ assert {:ok, [%Skill{name: "auto"}]} = Skills.skills(wb)
+ end
+
+ test "loads SKILL.md files when the explicit file list is empty" do
+ {wb, repo, ref} = git_workbench([])
+
+ expect(Git, :fetch, fn ^repo, ^ref ->
+ {:ok, [{"notes.md", "ignore me"}, {"cluster/SKILL.md", @skill}]}
+ end)
+
+ assert {:ok, [%Skill{name: "auto"}]} = Skills.skills(wb)
+ end
+ end
+
+ describe "skill_file/2" do
+ test "resolves an auto-detected SKILL.md when the explicit file list is missing" do
+ {wb, repo, ref} = git_workbench(nil)
+
+ expect(Git, :fetch, fn ^repo, ^ref ->
+ {:ok, [{"cluster/SKILL.md", @skill}]}
+ end)
+
+ assert {:ok, {^repo, "main", "skills/cluster/SKILL.md"}} = Skills.skill_file("auto", wb)
+ end
+ end
+
+ describe "skills_changeset/2" do
+ test "accepts a git ref without a file list" do
+ changeset = Workbench.skills_changeset(%Workbench.Skills{}, %{ref: %{ref: "main", folder: "skills"}})
+
+ assert changeset.valid?
+ assert Ecto.Changeset.get_change(changeset, :files) == nil
+ end
+
+ test "accepts an empty file list" do
+ changeset = Workbench.skills_changeset(%Workbench.Skills{}, %{ref: %{ref: "main", folder: "skills"}, files: []})
+
+ assert changeset.valid?
+ assert Ecto.Changeset.get_change(changeset, :files) == []
+ end
+ end
+
describe "plural?/2" do
test "returns false when db-backed skills list is empty" do
wb = struct(Workbench, %{workbench_skills: []})
@@ -139,4 +203,16 @@ defmodule Console.AI.Workbench.SkillsTest do
assert msg =~ "empty.md"
end
end
+
+ defp git_workbench(files) do
+ repo = struct(GitRepository, %{id: Ecto.UUID.generate(), url: "https://github.com/example/skills.git"})
+ ref = struct(Service.Git, %{ref: "main", folder: "skills"})
+ wb = struct(Workbench, %{
+ repository: repo,
+ skills: struct(Workbench.Skills, %{ref: ref, files: files}),
+ workbench_skills: []
+ })
+
+ {wb, repo, ref}
+ end
end
diff --git a/test/console/deployments/workbenches_test.exs b/test/console/deployments/workbenches_test.exs
index 57fd6baffd..5d4013df2c 100644
--- a/test/console/deployments/workbenches_test.exs
+++ b/test/console/deployments/workbenches_test.exs
@@ -930,6 +930,13 @@ defmodule Console.Deployments.WorkbenchesTest do
test "marks the job paused, cancels running activities, and notifies subscribers" do
job = insert(:workbench_job, status: :running)
activity = insert(:workbench_job_activity, workbench_job: job, status: :running)
+ running = insert(:agent_run, status: :running)
+ pending_approval = insert(:agent_run, status: :pending_approval)
+ babysitting = insert(:agent_run, status: :babysitting)
+
+ for run <- [running, pending_approval, babysitting] do
+ {:ok, _} = Workbenches.associate_agent_run(activity, run.id)
+ end
{:ok, paused} = Workbenches.pause_job(job)
@@ -937,6 +944,9 @@ defmodule Console.Deployments.WorkbenchesTest do
assert paused.status == :paused
assert refetch(job).status == :paused
assert refetch(activity).status == :cancelled
+ assert refetch(running).status == :cancelled
+ assert refetch(pending_approval).status == :pending_approval
+ assert refetch(babysitting).status == :babysitting
assert_receive {:event, %PubSub.WorkbenchJobUpdated{item: ^paused}}
end
@@ -2365,6 +2375,14 @@ defmodule Console.Deployments.WorkbenchesTest do
describe "fail_job/2" do
test "sets job status to failed, completed_at, and error message" do
job = insert(:workbench_job, status: :running)
+ activity = insert(:workbench_job_activity, workbench_job: job, status: :running)
+ running = insert(:agent_run, status: :running)
+ pending_approval = insert(:agent_run, status: :pending_approval)
+ babysitting = insert(:agent_run, status: :babysitting)
+
+ for run <- [running, pending_approval, babysitting] do
+ {:ok, _} = Workbenches.associate_agent_run(activity, run.id)
+ end
{:ok, failed} = Workbenches.fail_job("Something went wrong.", job)
@@ -2372,6 +2390,10 @@ defmodule Console.Deployments.WorkbenchesTest do
assert failed.status == :failed
assert failed.completed_at
assert failed.error == "Something went wrong."
+ assert refetch(activity).status == :cancelled
+ assert refetch(running).status == :cancelled
+ assert refetch(pending_approval).status == :pending_approval
+ assert refetch(babysitting).status == :babysitting
end
test "persists usage when provided" do
diff --git a/test/console/utils/http_test.exs b/test/console/utils/http_test.exs
new file mode 100644
index 0000000000..9c1d715cbd
--- /dev/null
+++ b/test/console/utils/http_test.exs
@@ -0,0 +1,39 @@
+defmodule Console.Utils.HTTPTest do
+ use ExUnit.Case, async: true
+
+ alias Console.Utils.HTTP
+
+ describe "proxy_options/2" do
+ test "builds proxy connect options" do
+ opts = HTTP.proxy_options(%{url: "http://proxy.example.com:3128"}, "https://registry.example.com/v2")
+
+ assert opts[:connect_options][:proxy] == {:http, "proxy.example.com", 3128, []}
+ end
+
+ test "defaults scheme-less proxy urls to http" do
+ assert HTTP.proxy_options(%{url: "proxy.example.com:3128"}, "https://registry.example.com")[:connect_options][:proxy] ==
+ {:http, "proxy.example.com", 3128, []}
+ assert HTTP.proxy_options(%{url: "10.0.0.1:3128"}, "https://registry.example.com")[:connect_options][:proxy] ==
+ {:http, "10.0.0.1", 3128, []}
+ assert HTTP.proxy_options(%{url: " proxy.example.com "}, "https://registry.example.com")[:connect_options][:proxy] ==
+ {:http, "proxy.example.com", 80, []}
+ assert HTTP.proxy_options(%{url: "https://proxy.example.com"}, "https://registry.example.com")[:connect_options][:proxy] ==
+ {:https, "proxy.example.com", 443, []}
+ end
+
+ test "skips the proxy for exact and suffix noproxy matches" do
+ proxy = %{url: "http://proxy.example.com:3128", noproxy: "localhost, .internal.example.com"}
+
+ assert HTTP.proxy_options(proxy, "http://localhost:5000/v2") == []
+ assert HTTP.proxy_options(proxy, "https://registry.internal.example.com/v2") == []
+ assert HTTP.proxy_options(proxy, "https://internal.example.com/v2") == []
+ assert HTTP.proxy_options(proxy, "https://registry.example.com/v2")[:connect_options][:proxy]
+ end
+
+ test "does nothing without a proxy url" do
+ assert HTTP.proxy_options(nil, "https://registry.example.com") == []
+ assert HTTP.proxy_options(%{url: nil}, "https://registry.example.com") == []
+ assert HTTP.proxy_options(%{url: ""}, "https://registry.example.com") == []
+ end
+ end
+end
diff --git a/test/oci/client_test.exs b/test/oci/client_test.exs
index ff2912e97e..92dff064ec 100644
--- a/test/oci/client_test.exs
+++ b/test/oci/client_test.exs
@@ -12,6 +12,74 @@ defmodule Console.OCI.ClientTest do
{:ok, %{tags: [_ | _]}} = Client.tags(client)
end
+
+ test "it can fetch and paginate tags from public.ecr.aws" do
+ client = Client.new("oci://public.ecr.aws/docker/library/nginx")
+
+ {:ok, %{name: "docker/library/nginx", tags: tags}} = Client.tags(client)
+
+ assert length(tags) > 1000
+ assert "latest" in tags
+ end
+ end
+
+ describe "#tags_page/2" do
+ test "it follows the registry's opaque cursor for public.ecr.aws" do
+ client = Client.new("oci://public.ecr.aws/docker/library/nginx")
+
+ {:ok, %{name: "docker/library/nginx", tags: [_, _, _] = page1, next_cursor: cursor}} =
+ Client.tags_page(client, page_size: 3)
+
+ assert is_binary(cursor)
+ refute cursor == List.last(page1)
+
+ {:ok, %{tags: [_, _, _] = page2}} = Client.tags_page(client, page_size: 3, cursor: cursor)
+ assert MapSet.disjoint?(MapSet.new(page1), MapSet.new(page2))
+ end
+
+ test "it returns a nil cursor on the last page" do
+ client = Client.new("oci://ghcr.io/pluralsh/console")
+ client = put_in(client.client, Req.merge(client.client, plug: fn conn ->
+ Req.Test.json(conn, %{"name" => "pluralsh/console", "tags" => ["0.1.0"]})
+ end))
+
+ assert {:ok, %{tags: ["0.1.0"], next_cursor: nil}} = Client.tags_page(client)
+ end
+ end
+
+ describe "proxy configuration" do
+ test "it configures the proxy as a req connect option" do
+ client = Client.new("oci://ghcr.io/pluralsh/console", %{url: "http://proxy.example.com:8080", noproxy: nil})
+
+ assert client.client.options.connect_options[:proxy] == {:http, "proxy.example.com", 8080, []}
+ refute Map.has_key?(client.client.options, :proxy)
+
+ client = put_in(client.client, Req.merge(client.client, plug: fn conn ->
+ Req.Test.json(conn, %{"name" => "pluralsh/console", "tags" => ["0.1.0"]})
+ end))
+
+ assert {:ok, %{tags: ["0.1.0"]}} = Client.tags(client)
+ end
+
+ test "it can add a proxy to an existing client" do
+ client =
+ Client.new("oci://ghcr.io/pluralsh/console")
+ |> Client.with_proxy(%{url: "https://proxy.example.com", noproxy: nil})
+
+ assert client.client.options.connect_options[:proxy] == {:https, "proxy.example.com", 443, []}
+ end
+
+ test "it supports proxy urls without a scheme" do
+ client = Client.new("oci://ghcr.io/pluralsh/console", %{url: "proxy.example.com:8080", noproxy: nil})
+
+ assert client.client.options.connect_options[:proxy] == {:http, "proxy.example.com", 8080, []}
+ end
+
+ test "it skips the proxy for noproxy hosts" do
+ client = Client.new("oci://ghcr.io/pluralsh/console", %{url: "http://proxy.example.com:8080", noproxy: "localhost,.ghcr.io"})
+
+ refute client.client.options[:connect_options][:proxy]
+ end
end
describe "download_blob/3" do