Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ jobs:
printf 'Pin: `%s@%s`\n' "$IMAGE" "$DIGEST"
} >> "$GITHUB_STEP_SUMMARY"

# Publishes charts/mainloop to oci://ghcr.io/oldsj/charts/mainloop as 0.1.<run number>, with this
# commit's image digests pinned in its values. Deployments track the newest 0.1.* version. Run
# numbers only grow, so a re-run or a late older run never outranks a newer commit's chart.
chart:
name: Publish the chart
needs: publish
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false

- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Package and push
shell: bash
env:
VERSION: 0.1.${{ github.run_number }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag="${GITHUB_SHA:0:7}"
digest_of() {
local d
d="$(docker buildx imagetools inspect "ghcr.io/oldsj/mainloop-$1:${tag}" --format '{{.Manifest.Digest}}')"
[[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "no digest for mainloop-$1:${tag}: '${d}'" >&2; exit 1; }
printf '%s' "$d"
}
backend="$(digest_of backend)" frontend="$(digest_of frontend)"
values=charts/mainloop/values.yaml
yq -i ".images.backend.tag = \"${tag}\" | .images.backend.digest = \"${backend}\"" "$values"
yq -i ".images.frontend.tag = \"${tag}\" | .images.frontend.digest = \"${frontend}\"" "$values"
rendered="$(helm template mainloop charts/mainloop)"
grep -qF "mainloop-backend@${backend}" <<<"$rendered"
grep -qF "mainloop-frontend@${frontend}" <<<"$rendered"
helm package charts/mainloop --version "$VERSION" --app-version "$tag" --destination "$RUNNER_TEMP"
helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin <<<"$GHCR_TOKEN"
helm push "$RUNNER_TEMP/mainloop-${VERSION}.tgz" oci://ghcr.io/oldsj/charts
{
printf '### Chart mainloop %s\n\n' "$VERSION"
printf 'Pull: `oci://ghcr.io/oldsj/charts/mainloop --version %s`\n\n' "$VERSION"
printf 'Backend: `%s`\n\n' "$backend"
printf 'Frontend: `%s`\n' "$frontend"
} >> "$GITHUB_STEP_SUMMARY"

# Pins production to this commit's images. The release branch is the main commit plus one commit
# that adds an images block to the prod overlay; Argo tracks it. Pushes made with GITHUB_TOKEN do
# not start workflows, so this cannot loop. A run releases only while its commit is still the tip of
Expand Down
4 changes: 4 additions & 0 deletions .trunk/trunk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ lint:
- linters: [hadolint, terrascan]
paths:
- '**/*Dockerfile*'
# Helm templates are Go templates, not YAML; helm lint and the deploytest renders cover them.
- linters: [yamllint, prettier, checkov, trivy]
paths:
- charts/*/templates/**
# Written by the TanStack Router plugin on every vite run; its output is committed as written
- linters: [ALL]
paths:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ dev-reset: ## Wipe the Kind database and restart the backend
@./scripts/kind/reset-data.sh

# =============================================================================
# Shared live instance (DevSpace profile `live`, namespace mainloop-dev on internal-01)
# Shared live instance (DevSpace profile `live`, namespace mainloop-dev; needs DEV_LIVE_CONTEXT and MAINLOOP_DEV_CONFIG)
# =============================================================================

dev-live: ## Deploy mainloop-dev and live-sync the Go server and web app into it
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,6 @@ database and let `mainloop migrate up` create the schema. Main and child Session
credential references; the main thread uses its dedicated main Agent. Native session history
from earlier development builds is not carried over.

The [Kind overlay](k8s/apps/mainloop/overlays/kind/README.md) runs the Go/React stack without kagent,
The [Kind setup](docs/dev-environment.md#kind) runs the Go/React stack without kagent,
so native sessions, workspaces and schedules are unavailable there. A tracked recipe for installing
kagent into a local cluster does not exist yet.
9 changes: 9 additions & 0 deletions charts/mainloop/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: v2
name: mainloop
description: Mainloop's Go server (REST, MCP and Git listeners) and React app.
type: application
# The Publish images workflow packages this chart as <major>.<minor>.<run number>, with
# appVersion set to the short commit SHA and the image digests pinned in values.yaml. Bump the
# minor version here for a values change a deployment has to act on.
version: 0.1.0
appVersion: dev
35 changes: 35 additions & 0 deletions charts/mainloop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Mainloop chart

Deploys Mainloop's Go server (the REST, MCP and Git listeners in one pod) and the React app, with
an optional database: a CloudNativePG `Cluster` (`database.cnpg.enabled`) or a single local
PostgreSQL StatefulSet (`database.local.enabled`).

CI publishes it on each push to `main` as `oci://ghcr.io/oldsj/charts/mainloop`, version
`0.1.<run number>`, with that commit's image digests pinned in its values. Track `0.1.*` to follow
`main`.

Every setting and its default is in [values.yaml](values.yaml). Example installs, which the render
tests in `server/internal/deploytest` check:

- [values-kind.yaml](values-kind.yaml): a local Kind cluster (`make kind-up`, `make dev`; see
[docs/dev-environment.md#kind](../../docs/dev-environment.md#kind)).
- [ci/production-values.yaml](ci/production-values.yaml): a production install with CNPG, kagent
control-plane auth and site settings from a `mainloop-site` ConfigMap.
- [ci/live-dev-values.yaml](ci/live-dev-values.yaml): a live-sync dev instance run by DevSpace
(`make dev-live`; see [docs/dev-environment.md#live-dev-instance](../../docs/dev-environment.md#live-dev-instance)).

The chart reads, but does not create, Secret `mainloop-secrets` (GitHub App keys, agent token key,
and database credentials when CNPG is off), and with `kagentControlAuth.enabled` Secrets
`kagent-control-token` and `kagent-control-ca`. Routes, gateway network policy peers and kagent
objects belong to the deployment.

## Kind

- Images `mainloop-backend:kind` and `mainloop-frontend:kind`, loaded into Kind; no pull secret.
- Settings for `http://localhost:5173` and `MAINLOOP_ALLOW_RESET=1`, which lets `make dev-reset`
wipe the database. The frontend `ORIGIN`, `FRONTEND_SCHEME` and `FRONTEND_DOMAIN` must match the
port forward, so change them together.
- Works without kagent: the web app, the owner API and the MCP and Git listeners (Git transport
off). Native sessions, workspaces and schedules need kagent, so `GET /schedules` returns 502.
- No GitHub App, and Kind's default CNI does not enforce the network policies. The secrets are
random values created on your machine; do not reuse them.
164 changes: 164 additions & 0 deletions charts/mainloop/ci/live-dev-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# A generic live-sync instance (DevSpace profile `live`), rendered by server/internal/deploytest. A
# real deployment keeps its own values file and passes it as MAINLOOP_DEV_VALUES.

# The -dev images carry the Go toolchain (server) and Vite (web), built from main. They are public.
images:
backend:
repository: ghcr.io/oldsj/mainloop-server-dev
tag: main
frontend:
repository: ghcr.io/oldsj/mainloop-web-dev
tag: main
pullPolicy: Always
imagePullSecrets: []

# Images are published for arm64 only.
nodeSelector:
kubernetes.io/arch: arm64

database:
cnpg:
enabled: true
# local-path storage is deleted with the namespace.
storage:
size: 2Gi
storageClass: local-path
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi

kagentControlAuth:
enabled: true

# Each server container runs watchexec over `go run` from /src, which DevSpace syncs server/ into;
# the same commands run when the pods start without `devspace dev`. Each container rebuilds into
# its own Go build cache, on top of the warm cache baked into the image, so each gets its own
# ephemeral-storage budget; a 3Gi pod total was evicted after about ten hot reloads.
server:
strategy:
type: Recreate
importSecretEnv: false
# The instance starts without the GitHub App; AGENT_TOKEN_KEY is still required.
githubAppOptional: true
command: [watchexec, --restart, --exts, 'go,sql', --stop-timeout, 5s, --, go, run, ./cmd/mainloop]
workingDir: /src
# `go run` compiles first, so the probes wait for a cold build.
livenessProbe:
initialDelaySeconds: 120
failureThreshold: 6
readinessProbe:
initialDelaySeconds: 20
failureThreshold: 60
api:
resources:
requests:
cpu: 100m
memory: 512Mi
ephemeral-storage: 1Gi
limits:
cpu: '2'
memory: 2Gi
ephemeral-storage: 8Gi
mcp:
resources:
requests:
cpu: 50m
memory: 256Mi
ephemeral-storage: 1Gi
limits:
cpu: '2'
memory: 2Gi
ephemeral-storage: 8Gi
git:
resources:
requests:
cpu: 25m
memory: 128Mi
ephemeral-storage: 1Gi
limits:
cpu: '2'
memory: 2Gi
ephemeral-storage: 8Gi
# The dev image keeps the source it was built from at /src, so migrate runs the image's own
# revision before the first sync.
migrate:
command: [go, run, ./cmd/mainloop, migrate, up]
workingDir: /src
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 1Gi
limits:
cpu: '2'
memory: 2Gi
ephemeral-storage: 8Gi

# Vite dev server with HMR. The browser calls /api on the same host and Vite forwards it to the
# backend Service.
frontend:
command: [/app/web/node_modules/.bin/vite]
workingDir: /app/web
env:
- name: VITE_HOST
value: 0.0.0.0
- name: VITE_PORT
value: '3000'
- name: VITE_API_URL
value: /api
- name: MAINLOOP_API_PROXY
value: http://mainloop-backend:8000
# Vite rejects unknown Host headers; allow the gateway host.
- name: __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS
value: mainloop-dev.example.test
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '2'
memory: 2Gi
livenessProbe:
initialDelaySeconds: 90
readinessProbe:
initialDelaySeconds: 20
failureThreshold: 12

config:
FRONTEND_DOMAIN: mainloop-dev.example.test
FRONTEND_SCHEME: https
MAINLOOP_API_HOSTS: mainloop-backend,mainloop-backend.mainloop-dev,mainloop-backend.mainloop-dev.svc,mainloop-backend.mainloop-dev.svc.cluster.local
MAINLOOP_OWNER_ID: dev-owner
# Hosts the MCP listener accepts; in-cluster it must include its own Service name.
MAINLOOP_MCP_HOSTS: mainloop-mcp,mainloop-mcp.mainloop-dev,mainloop-mcp.mainloop-dev.svc,mainloop-mcp.mainloop-dev.svc.cluster.local
# Lets `mainloop migrate reset` run in this prototype instance.
MAINLOOP_ALLOW_RESET: '1'
DEV_MODE: 'true'
IS_TEST_ENV: 'false'
NATIVE_CHILD_KINDS: claude,codex
# Derives the MCP and Git origins (*.mainloop-dev.svc.cluster.local) and the mainloop-dev- prefix
# of this instance's credential Secrets in the kagent namespace.
MAINLOOP_NAMESPACE: mainloop-dev
# On: dev Git goes through mainloop-dev's own read/push proxies, not production's. kagent's
# service policy accepts them once its instance allowlist includes mainloop-dev.
GIT_TRANSPORT_ENABLED: 'true'
PUSH_GATE_ENABLED: 'true'
MAINLOOP_MERGE_TOOLS_ENABLED: 'false'
MAINLOOP_OWNER_HITL_WRITES_ENABLED: 'true'
# Dev-only kagent agents; production agents are never used from here.
KAGENT_NAMESPACE: kagent
KAGENT_ACTOR_ATESPACE: kagent
KAGENT_MAIN_AGENT: claude-workspace-dev
# Codex main uses the dev workspace Agent, like the Claude main. The default (mainloop-main-codex)
# is production's Agent on this shared kagent.
KAGENT_MAIN_CODEX_AGENT: codex-workspace-dev
KAGENT_CLAUDE_AGENT: claude-workspace-dev
KAGENT_CODEX_AGENT: codex-workspace-dev
KAGENT_WORKSPACE_CLAUDE_AGENT: claude-workspace-dev
KAGENT_WORKSPACE_CODEX_AGENT: codex-workspace-dev
KAGENT_SUPERVISOR_CLAUDE_AGENT: claude-workspace-dev
KAGENT_SUPERVISOR_CODEX_AGENT: codex-workspace-dev
97 changes: 97 additions & 0 deletions charts/mainloop/ci/production-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# A generic production install, rendered by server/internal/deploytest. A real deployment keeps its
# own values file (domains, agents, owner decisions) in its deployment repository.

# A GitOps owner tracks the Namespace, so keep it in the render.
createNamespace: true

server:
# The API process runs the reconcilers and strict projection readers and writers must stay on
# one model version, so replace pods instead of surging a second one.
strategy:
type: Recreate
podAnnotations:
# mainloop-site is read through envFrom, so editing it does not restart the backend. Change
# this value with each site-config change to roll the backend through GitOps.
example.test/site-config: example-1
# Only the keys the server needs, not the whole mainloop-secrets item.
importSecretEnv: false
# FRONTEND_DOMAIN, API_DOMAIN, MAINLOOP_OWNER_ID, SUBSTRATE_PREVIEW_BASE_URL and feature flags.
extraEnvFrom:
- configMapRef:
name: mainloop-site
# Idle use is about 50Mi for the API and 25Mi each for MCP and Git; the limits keep headroom.
api:
resources:
requests:
memory: 256Mi
cpu: 250m
limits:
memory: 2Gi
cpu: 1000m
mcp:
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
git:
resources:
requests:
memory: 128Mi
cpu: 100m
ephemeral-storage: 1Gi
limits:
memory: 2Gi
cpu: 1000m
ephemeral-storage: 3Gi

frontend:
env:
- name: ORIGIN
valueFrom:
configMapKeyRef:
name: mainloop-site
key: FRONTEND_ORIGIN

database:
cnpg:
enabled: true

kagentControlAuth:
enabled: true

config:
# Domains come from mainloop-site.
FRONTEND_DOMAIN: null
FRONTEND_SCHEME: https
MAINLOOP_API_HOSTS: mainloop-backend,mainloop-backend.mainloop,mainloop-backend.mainloop.svc,mainloop-backend.mainloop.svc.cluster.local
DEV_MODE: 'false'
IS_TEST_ENV: 'false'
KAGENT_GATEWAY_URL: http://kagent-controller.kagent.svc.cluster.local:8083
KAGENT_USER_ID: mainloop
KAGENT_NAMESPACE: kagent
KAGENT_ACTOR_ATESPACE: kagent
KAGENT_MAIN_AGENT: mainloop-main
KAGENT_CLAUDE_AGENT: claude-subscription
KAGENT_CODEX_AGENT: codex-subscription-https
KAGENT_WORKSPACE_CLAUDE_AGENT: claude-workspace
KAGENT_WORKSPACE_CODEX_AGENT: codex-workspace
SUBSTRATE_ROUTER_ADDRESS: http://atenet-router.ate-system.svc.cluster.local:8081

networkPolicy:
# Only the actor egress proxy reaches MCP. The browser reaches the API through the gateway, whose
# peer the deployment adds as a separate policy. The frontend makes no server-side calls to the
# backend.
backendIngress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ate-system
podSelector:
matchLabels:
app: atenet-egress
ports:
- protocol: TCP
port: 8002
Loading
Loading