Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d0b622d
feat: add spawner app for one-click coding-agents provisioning
dgokeeffe Mar 11, 2026
52478b1
feat: spawner admin bootstrap, SCIM identity, apps list
dgokeeffe Mar 11, 2026
0e658f0
feat: add Databricks-themed UI for spawner app
dgokeeffe Mar 11, 2026
ed9d2a0
feat: adopt native uv support for spawner app
dgokeeffe Mar 13, 2026
c4bb558
fix: surface deploy API error body in spawner
dgokeeffe Mar 13, 2026
acffae0
fix: use temp file for template app.yaml upload
dgokeeffe Mar 13, 2026
c7b9e5a
fix: wait for app RUNNING state before deploying
dgokeeffe Mar 13, 2026
339cb35
fix: retry deploy instead of waiting for RUNNING state
dgokeeffe Mar 13, 2026
33b96b5
fix: wait for compute ACTIVE before deploy, bump gunicorn timeout
dgokeeffe Mar 13, 2026
de52735
fix: truncate app names exceeding 63-char limit
dgokeeffe Mar 17, 2026
3f7ef1d
fix: use deterministic secret key, skip re-provision
dgokeeffe Mar 17, 2026
6c9b0f6
fix: strip whitespace from ADMIN_TOKEN env var
dgokeeffe Mar 17, 2026
381e269
fix: use app_status instead of status in check_existing_app
dgokeeffe Mar 17, 2026
7dd80d0
feat: async provisioning with live progress tracking
dgokeeffe Mar 17, 2026
1f47a0d
fix: use opus model for spawned apps template
dgokeeffe Mar 17, 2026
858e357
fix: derive app state from compute + deployment status
dgokeeffe Mar 17, 2026
606e813
fix: show spinner during initial PAT verification step
dgokeeffe Mar 17, 2026
b4e6a42
feat: add redeploy-all endpoint and UI to spawner
dgokeeffe Mar 17, 2026
8d34bd4
fix: show per-app error details in redeploy-all UI
dgokeeffe Mar 17, 2026
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
128 changes: 128 additions & 0 deletions spawner/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Makefile for deploying Coding Agents Spawner to Databricks Apps
#
# Usage:
# make deploy PROFILE=daveok ADMIN_PAT=dapi... # full deploy (first time)
# make redeploy PROFILE=daveok # sync + deploy only
# make status PROFILE=daveok
# make logs PROFILE=daveok

PROFILE ?= DEFAULT
APP_NAME := coding-agents-spawner
SECRET_SCOPE := $(APP_NAME)-secrets
SECRET_KEY := admin-token
TEMPLATE_SRC := /Workspace/Shared/apps/coding-agents

USER_EMAIL = $(shell databricks current-user me --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('userName',''))")
WORKSPACE_PATH = /Workspace/Users/$(USER_EMAIL)/apps/$(APP_NAME)
HOST = $(shell databricks auth env --profile $(PROFILE) 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['env']['DATABRICKS_HOST'])")
TOKEN = $(shell databricks auth token --profile $(PROFILE) 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

.PHONY: help run deploy redeploy create-app setup-secret sync-template sync deploy-app status logs clean

run: ## Wait for app to be running and print URL
@echo "==> Waiting for '$(APP_NAME)' to be running..."
@for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \
STATE=$$(databricks apps get $(APP_NAME) --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('app_status',{}).get('state',''))"); \
if [ "$$STATE" = "RUNNING" ]; then \
echo ""; \
echo "App is RUNNING!"; \
databricks apps get $(APP_NAME) --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print('URL:', json.load(sys.stdin).get('url','(unknown)'))"; \
exit 0; \
fi; \
echo " State: $$STATE (waiting...)"; \
sleep 10; \
done; \
echo " Timed out waiting for app to reach RUNNING state."

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'

deploy: create-app setup-secret sync-template sync deploy-app run ## Full deploy: create app, set admin secret, sync template + spawner, deploy + run

redeploy: sync deploy-app run ## Redeploy: sync spawner + deploy (skip secret setup)

create-app: ## Create the spawner app (idempotent)
@echo "==> Checking if app '$(APP_NAME)' exists..."
@if databricks apps get $(APP_NAME) --profile $(PROFILE) >/dev/null 2>&1; then \
echo " App '$(APP_NAME)' already exists, skipping create."; \
else \
echo " Creating app '$(APP_NAME)'..."; \
databricks apps create $(APP_NAME) --profile $(PROFILE); \
fi

setup-secret: ## Create secret scope, store admin PAT, link to app
@echo "==> Setting up ADMIN_TOKEN secret..."
@# Create scope if it doesn't exist
@if databricks secrets list-scopes --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; scopes=json.load(sys.stdin); names=[s['name'] for s in (scopes if isinstance(scopes,list) else scopes.get('scopes',[]))]; exit(0 if '$(SECRET_SCOPE)' in names else 1)" 2>/dev/null; then \
echo " Secret scope '$(SECRET_SCOPE)' already exists."; \
else \
echo " Creating secret scope '$(SECRET_SCOPE)'..."; \
databricks secrets create-scope $(SECRET_SCOPE) --profile $(PROFILE); \
fi
@# Store the admin PAT
@if [ -z "$(ADMIN_PAT)" ]; then \
echo " Enter the admin PAT (will not echo):"; \
read -s pat_value && \
echo "$$pat_value" | databricks secrets put-secret $(SECRET_SCOPE) $(SECRET_KEY) --profile $(PROFILE); \
else \
echo "$(ADMIN_PAT)" | databricks secrets put-secret $(SECRET_SCOPE) $(SECRET_KEY) --profile $(PROFILE); \
fi
@echo " Secret stored in $(SECRET_SCOPE)/$(SECRET_KEY)"
@# Grant spawner SP READ on the scope
@echo " Granting spawner SP access..."
@SP_CLIENT_ID=$$(databricks apps get $(APP_NAME) --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('service_principal_client_id',''))") && \
if [ -n "$$SP_CLIENT_ID" ]; then \
databricks secrets put-acl $(SECRET_SCOPE) $$SP_CLIENT_ID READ --profile $(PROFILE); \
echo " Granted READ to $$SP_CLIENT_ID"; \
else \
echo " WARNING: Could not find SP client ID, skipping ACL grant."; \
fi
@# Link secret to app resource
@echo " Linking secret to app resource 'ADMIN_TOKEN'..."
@curl -s -X PATCH \
"$(HOST)/api/2.0/apps/$(APP_NAME)" \
-H "Authorization: Bearer $(TOKEN)" \
-H "Content-Type: application/json" \
-d '{"resources":[{"name":"ADMIN_TOKEN","description":"Admin PAT for provisioning operations","secret":{"scope":"$(SECRET_SCOPE)","key":"$(SECRET_KEY)","permission":"READ"}}]}' \
>/dev/null
@echo " App resource linked."

sync-template: ## Sync coding-agents source to shared template path
@echo "==> Syncing coding-agents template to $(TEMPLATE_SRC)..."
@databricks workspace mkdirs /Workspace/Shared/apps --profile $(PROFILE) 2>/dev/null || true
@cd .. && databricks sync . $(TEMPLATE_SRC) --watch=false --profile $(PROFILE)
@# Override app.yaml with spawner-friendly defaults (no gateway valueFrom)
@echo " Uploading template app.yaml..."
@# Resolve team-memory-mcp app URL (if deployed)
$(eval TEAM_MEMORY_URL := $(shell databricks apps get team-memory-mcp --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('url',''))" 2>/dev/null))
@printf 'command:\n - uv\n - run\n - gunicorn\n - app:app\nenv:\n - name: HOME\n value: /app/python/source_code\n - name: DATABRICKS_TOKEN\n valueFrom: DATABRICKS_TOKEN\n - name: ANTHROPIC_MODEL\n value: databricks-claude-opus-4-6\n - name: GEMINI_MODEL\n value: databricks-gemini-3-1-pro\n - name: CODEX_MODEL\n value: databricks-gpt-5-2\n - name: DATABRICKS_GATEWAY_HOST\n valueFrom: DATABRICKS_GATEWAY_HOST\n - name: CLAUDE_CODE_DISABLE_AUTO_MEMORY\n value: 0\n' > /tmp/_coda_template_app.yaml
@if [ -n "$(TEAM_MEMORY_URL)" ]; then \
printf ' - name: TEAM_MEMORY_MCP_URL\n value: %s\n' "$(TEAM_MEMORY_URL)" >> /tmp/_coda_template_app.yaml; \
echo " Team memory MCP URL: $(TEAM_MEMORY_URL)"; \
else \
echo " Team memory MCP: not deployed (skipping)"; \
fi
@databricks workspace import $(TEMPLATE_SRC)/app.yaml --file /tmp/_coda_template_app.yaml --format AUTO --overwrite --profile $(PROFILE)
@rm -f /tmp/_coda_template_app.yaml
@echo " Template synced."

sync: ## Sync spawner source to workspace
@echo "==> Syncing spawner to $(WORKSPACE_PATH)..."
@databricks sync . $(WORKSPACE_PATH) --watch=false --profile $(PROFILE)

deploy-app: ## Deploy the spawner app
@echo "==> Deploying '$(APP_NAME)'..."
@databricks apps deploy $(APP_NAME) --source-code-path $(WORKSPACE_PATH) --profile $(PROFILE) --no-wait
@echo ""
@echo "App URL:"
@databricks apps get $(APP_NAME) --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('url','(pending)'))"

status: ## Check spawner app status
@databricks apps get $(APP_NAME) --profile $(PROFILE)

logs: ## Tail spawner app logs
@databricks apps logs $(APP_NAME) --profile $(PROFILE)

clean: ## Remove secret scope (destructive)
@echo "==> Removing secret scope '$(SECRET_SCOPE)'..."
@databricks secrets delete-scope $(SECRET_SCOPE) --profile $(PROFILE)
111 changes: 111 additions & 0 deletions spawner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Coding Agents Spawner

One-click provisioning of individual [coding-agents](../) Databricks Apps for any developer in your workspace.

## How It Works

A developer visits the spawner UI, pastes their Databricks PAT, and clicks **Deploy**. The spawner:

1. **Resolves identity** — calls SCIM `/Me` with the user's PAT to get their email
2. **Stores the PAT** — creates a secret scope `coding-agents-{user}-secrets` and stores the PAT with a unique UUID key (uses admin token for privileged scope operations)
3. **Creates the app** — `POST /api/2.0/apps` with the user's PAT so they own it; the secret resource (`DATABRICKS_TOKEN`) is included in the creation call
4. **Grants SP access** — gives the app's service principal READ on the secret scope
5. **Deploys** — deploys from the shared template at `/Workspace/Shared/apps/coding-agents`

The spawned app is named `coding-agents-{username}` (derived from email), e.g., `coding-agents-david-okeeffe`.

## Architecture

```
┌─────────────────────┐ ┌──────────────────────────┐
│ Spawner App │ │ Shared Template │
│ (this app) │ │ /Workspace/Shared/apps/ │
│ │ deploy │ coding-agents/ │
│ - Admin PAT (env) ├────────►│ - app.py │
│ - Provisioning API │ │ - app.yaml │
│ - Spawned apps list│ │ - requirements.txt │
└─────────────────────┘ └──────────────────────────┘
│ creates per-user
┌─────────────────────────────┐
│ coding-agents-{user} │
│ - Owned by user │
│ - DATABRICKS_TOKEN = PAT │
│ - Deployed from template │
└─────────────────────────────┘
```

### Token Model

| Token | Stored in | Used for |
|-------|-----------|----------|
| **Admin PAT** | `coding-agents-spawner-secrets/admin-token` | Secret scope creation, ACLs, deployment |
| **User PAT** | `coding-agents-{user}-secrets/{uuid}` | App creation (ownership), runtime `DATABRICKS_TOKEN` |

The admin PAT requires **workspace admin** privileges (for secret scope creation and ACL management).

The user PAT should have **all access** scopes since Claude Code uses it for model serving, workspace operations, Unity Catalog, clusters, etc.

## Prerequisites

- Databricks CLI configured with a profile (`databricks configure --profile <name>`)
- Workspace admin access (for the admin PAT)
- Shared template synced to `/Workspace/Shared/apps/coding-agents`

## Deploy

### First time

```bash
cd spawner
make deploy PROFILE=daveok ADMIN_PAT=dapi...
```

This will:
- Create the `coding-agents-spawner` app
- Create secret scope and store the admin PAT
- Sync the coding-agents template to the shared workspace path
- Sync the spawner source and deploy
- Wait for the app to be RUNNING and print the URL

If you omit `ADMIN_PAT`, it will prompt interactively.

### Subsequent deploys

```bash
make redeploy PROFILE=daveok
```

Syncs source and redeploys (skips secret setup and template sync).

### Other targets

```bash
make status PROFILE=daveok # Check app status
make logs PROFILE=daveok # Tail app logs
make sync-template PROFILE=daveok # Re-sync shared template
make clean PROFILE=daveok # Remove secret scope (destructive)
make help # Show all targets
```

## API Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Spawner UI |
| `/health` | GET | Health check |
| `/api/status` | GET | Check if current user has a deployed app |
| `/api/apps` | GET | List all spawned coding-agents apps |
| `/api/provision` | POST | Provision a new app (body: `{"pat": "dapi..."}`) |

## Files

```
spawner/
├── app.py # Flask app with provisioning logic
├── app.yaml # Databricks App config (exposes ADMIN_TOKEN env)
├── requirements.txt # flask, gunicorn, requests
├── Makefile # Deploy/manage targets
└── README.md # This file
```
Empty file added spawner/__init__.py
Empty file.
Loading