Compare commits

...
8 Commits
Author SHA1 Message Date
Daniel SamsonandClaude Opus 4.6 a6ff7b7fb5 Stop workspace before deleting — Coder returns 405 on running workspaces
Publish Image / publish (push) Successful in 21s
The DELETE API requires the workspace to be stopped first. Now stops,
waits 10s for shutdown, then deletes. This was causing workspaces to
never be cleaned up after task completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:18:54 +01:00
Daniel SamsonandClaude Opus 4.6 b43e38ec54 Comprehensive setup guide for adding new projects
Documents the full process: adding slash commands, bot collaborator
access, Gitea webhook configuration, infrastructure setup, env vars,
and how workspaces work. Covers common gotchas (OAuth token priority,
org vs repo permissions, lightweight vs heavy stages).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:03:40 +01:00
Daniel SamsonandClaude Opus 4.6 b42b325dd3 Remove ANTHROPIC_API_KEY from workspace — it overrides OAuth token
ANTHROPIC_API_KEY takes priority over CLAUDE_CODE_OAUTH_TOKEN in
Claude Code's auth precedence. Must be unset for OAuth to work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:55:12 +01:00
Daniel SamsonandClaude Opus 4.6 abbe47b1b9 Support Claude Code Max via OAuth token
Publish Image / publish (push) Successful in 21s
Pass CLAUDE_CODE_OAUTH_TOKEN to workspaces so they use the Max
subscription instead of pay-per-use API credits. Falls back to
ANTHROPIC_API_KEY if OAuth token not set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:52:01 +01:00
Daniel SamsonandClaude Opus 4.6 9bca465565 Skip build for lightweight stages (analyse, architect, release, maintenance)
These stages only read code and post comments — no need for npm install,
frontend build, Playwright, or migrations. Saves several minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:47:48 +01:00
Daniel SamsonandClaude Opus 4.6 d220623a9f Always fire callback via trap, even on script failure
Uses ERR/EXIT trap to ensure the orchestrator is notified when the
startup script fails (e.g. clone error, missing command file). Prevents
orphaned workspaces that never get cleaned up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:42:18 +01:00
Daniel SamsonandClaude Opus 4.6 e801d96410 Ephemeral workspaces: remove PVC, use emptyDir
Workspaces are now fully immutable like GH Actions runners — no
persistent volume, no init container, no stale state between runs.
Fresh emptyDir on every workspace creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:27:30 +01:00
Daniel SamsonandClaude Opus 4.6 869d82998b Fresh clone on every automated task, generic repo setup
Always rm -rf ~/project before cloning to avoid stale state from
previous workspace runs on the same PVC. Made setup steps generic
(detect frontend, playwright, migrations) instead of babble-specific.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 14:25:17 +01:00
5 changed files with 273 additions and 176 deletions
+206 -61
View File
@@ -1,39 +1,184 @@
# SDLC Orchestrator # SDLC Orchestrator
Lightweight webhook-driven orchestrator that replaces n8n for the Gitea + Coder + Claude Code SDLC pipeline. Lightweight webhook-driven orchestrator for the Gitea + Coder + Claude Code SDLC pipeline. Receives Gitea webhooks, creates ephemeral Coder workspaces running Claude Code, and cleans up when done.
Receives Gitea webhooks, routes them to the correct SDLC stage, creates Coder workspaces, and posts status comments back on issues/PRs.
## Architecture ## Architecture
``` ```
Gitea Webhooks → SDLC Orchestrator → Coder API (creates workspace) Gitea Webhook → Orchestrator → Coder API (create workspace)
→ Gitea API (posts comments) → Gitea API (post status comment)
Workspace runs Claude Code → reads issue → does work → posts results to Gitea
→ POST /webhook/task-complete/:name (callback)
Orchestrator receives callback → deletes workspace via Coder API
``` ```
~500 lines of TypeScript. No database, no UI, no state — just a webhook router. ## Adding a New Project
## Webhook Endpoints To add a new Gitea repo to the SDLC pipeline:
| Endpoint | Gitea Event | Action | ### 1. Add Claude Code slash commands to the repo
|----------|-------------|--------|
| `POST /webhook/gitea-issue-triage` | Issue opened | Route to `analyse` or `fix-bug` |
| `POST /webhook/gitea-issue-label` | Issue labeled | Stage transition (architect/develop/test/devops) |
| `POST /webhook/gitea-issue-comment` | Comment created | Re-trigger analyst if still in analysis |
| `POST /webhook/gitea-pr-review` | PR opened/synced | Trigger code review |
| `POST /webhook/gitea-pr-review-rework` | Review submitted | Route to `rework-pr` or `test` |
| `POST /webhook/gitea-release` | Release issue | Tag RC or production release |
| `GET /health` | — | Health check |
Plus a cron job (Monday 9 AM) for weekly maintenance. Create `.claude/commands/` in the repo with command files for each SDLC stage:
## Setup ```
.claude/commands/
├── analyse.md # Business analyst — requirements & acceptance criteria
├── architect.md # Systems architect — design doc & spec
├── develop.md # Developer — implement & create PR
├── review-code.md # Reviewer — approve or request changes
├── test.md # QA — test report & additional tests
├── fix-bug.md # Bug fix — skip analyse/architect
├── rework-pr.md # Address review feedback
├── release.md # Tag RC or production release
├── devops.md # Migration & deployment
└── maintenance.md # Weekly maintenance tasks
```
Each file uses this format:
```markdown
---
description: What this command does
allowed-tools: Read, Bash, Glob, Grep
---
You are a **Role Name**. Your job is to...
## Process
1. **Read the issue**:
```bash
curl -s "https://gitea.samson.media/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}/issues/$ARGUMENTS" \
-H "Authorization: token ${GITEA_TOKEN}" | jq '{title, body, labels: [.labels[].name]}'
```
2. **Do your work...**
3. **Post a comment**:
```bash
curl -X POST "https://gitea.samson.media/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}/issues/$ARGUMENTS/comments" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "..."}'
```
```
`$ARGUMENTS` is replaced with the issue/PR number at runtime.
### 2. Add bot users as collaborators
Add both `claude-dev` and `claude-review` as **direct collaborators** with **Write** access on the repo. Org membership alone is not sufficient.
### 3. Configure Gitea webhooks
In the repo's **Settings → Webhooks**, create these webhooks:
| # | URL | Events |
|---|-----|--------|
| 1 | `https://sdlc.samson.media/webhook/gitea-issue-triage` | Issues: `opened` |
| 2 | `https://sdlc.samson.media/webhook/gitea-issue-label` | Issues: `labeled` |
| 3 | `https://sdlc.samson.media/webhook/gitea-issue-comment` | Issue Comment: `created`, `edited` |
| 4 | `https://sdlc.samson.media/webhook/gitea-pr-review` | Pull Request: `opened`, `synchronized` |
| 5 | `https://sdlc.samson.media/webhook/gitea-pr-review-rework` | Pull Request Review: `submitted` |
| 6 | `https://sdlc.samson.media/webhook/gitea-release` | Issues: `opened`, `labeled` |
All webhooks use:
- Content type: `application/json`
- Method: `POST`
### 4. (Optional) Add to maintenance cron
To include the repo in weekly maintenance, add it to the `MAINTENANCE_REPOS` env var:
```
MAINTENANCE_REPOS=org1/repo1=https://gitea.samson.media/org1/repo1.git,org2/repo2=https://gitea.samson.media/org2/repo2.git
```
### 5. Test it
Create an issue in the repo. The orchestrator will:
1. Receive the `issues/opened` webhook
2. Create a Coder workspace running `/analyse`
3. Claude reads the issue, posts clarifying questions as a comment
4. Workspace is deleted automatically when done
## SDLC Flow
| Stage | Trigger | Task Type | Bot Account |
|-------|---------|-----------|-------------|
| Analyse | Issue opened / comment | `analyse` | claude-dev |
| Architect | Label: `ready-for-architecture` | `architect` | claude-dev |
| Develop | Label: `ready-for-development` | `develop` | claude-dev |
| Review | PR opened/updated | `review-code` | claude-review |
| Rework | Review: changes requested | `rework-pr` | claude-dev |
| Test | Review: approved | `test` | claude-review |
| Deploy | Label: `ready-for-deployment` | `devops` | claude-dev |
| Fix Bug | Issue opened with `bug` label | `fix-bug` | claude-dev |
## Infrastructure Setup
### Prerequisites ### Prerequisites
- Node.js 22+ - k3s cluster with Traefik ingress and cert-manager
- Coder running with the `cloudflare-worker` template (see `coder/`) - Coder instance (e.g. `coder.samson.media`)
- Gitea with two bot accounts: `claude-dev` and `claude-review` - Gitea instance (e.g. `gitea.samson.media`)
- Docker registry (e.g. `registry.samson.media`)
- Claude Code Max subscription (for OAuth token)
### Deploy the Orchestrator
1. **Build the workspace image** (pre-installed Node.js, Claude Code, Playwright deps):
```bash
docker build -f coder/workspace.Dockerfile -t registry.samson.media/coder-workspace:latest .
docker push registry.samson.media/coder-workspace:latest
```
2. **Push the Coder template**:
```bash
coder templates push cloudflare-worker --directory coder/cloudflare-worker --yes
```
Note the template ID from the Coder dashboard.
3. **Create Gitea bot accounts**:
- `claude-dev` — used for most stages (analyse, develop, etc.)
- `claude-review` — used for review and test stages
- Generate API tokens for each
4. **Generate Claude Code OAuth token** (uses Max subscription instead of API credits):
```bash
claude setup-token
```
This opens a browser for auth and outputs a long-lived token (`sk-ant-oat01-...`).
5. **Create k8s secrets**:
```bash
kubectl create namespace sdlc-orchestrator
kubectl create secret generic orchestrator-secrets -n sdlc-orchestrator \
--from-literal=CODER_URL=https://coder.samson.media \
--from-literal=CODER_TOKEN=<coder-session-token> \
--from-literal=CODER_TEMPLATE_ID=<template-id> \
--from-literal=GITEA_DEV_TOKEN=<claude-dev-token> \
--from-literal=GITEA_REVIEW_TOKEN=<claude-review-token> \
--from-literal=CLAUDE_OAUTH_TOKEN=<oauth-token-from-step-4> \
--from-literal=CALLBACK_URL=https://sdlc.samson.media \
--from-literal=GITEA_URL=https://gitea.samson.media \
--from-literal=BOT_DEV_USERNAME=claude-dev \
--from-literal=BOT_REVIEW_USERNAME=claude-review
```
6. **Deploy**:
```bash
# Tag to trigger CI build
git tag -a v1.0.0 -m "Initial release"
git push origin main --tags
# Or deploy manually
docker build -t registry.samson.media/sdlc-orchestrator:latest .
docker push registry.samson.media/sdlc-orchestrator:latest
kubectl apply -f k8s/deployment.yaml
```
### Environment Variables ### Environment Variables
@@ -43,58 +188,58 @@ Plus a cron job (Monday 9 AM) for weekly maintenance.
| `CODER_URL` | Yes | Coder API base URL | | `CODER_URL` | Yes | Coder API base URL |
| `CODER_TOKEN` | Yes | Coder session token | | `CODER_TOKEN` | Yes | Coder session token |
| `CODER_TEMPLATE_ID` | Yes | Coder workspace template ID | | `CODER_TEMPLATE_ID` | Yes | Coder workspace template ID |
| `GITEA_DEV_TOKEN` | Yes | Gitea API token for `claude-dev` | | `GITEA_DEV_TOKEN` | Yes | Gitea API token for claude-dev |
| `GITEA_REVIEW_TOKEN` | Yes | Gitea API token for `claude-review` | | `GITEA_REVIEW_TOKEN` | Yes | Gitea API token for claude-review |
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key for Claude Code | | `CLAUDE_OAUTH_TOKEN` | Yes | Claude Code Max OAuth token |
| `CALLBACK_URL` | Yes | Public URL of this service (e.g. `https://sdlc.samson.media`) |
| `GITEA_URL` | No | Gitea base URL (default: `https://gitea.samson.media`) | | `GITEA_URL` | No | Gitea base URL (default: `https://gitea.samson.media`) |
| `BOT_DEV_USERNAME` | No | Dev bot username (default: `claude-dev`) | | `BOT_DEV_USERNAME` | No | Dev bot username (default: `claude-dev`) |
| `BOT_REVIEW_USERNAME` | No | Review bot username (default: `claude-review`) | | `BOT_REVIEW_USERNAME` | No | Review bot username (default: `claude-review`) |
| `QUEUE_CONCURRENCY` | No | Max concurrent workspaces (default: 2) |
| `MAINTENANCE_REPOS` | No | Comma-separated `org/repo=clone_url` for weekly maintenance | | `MAINTENANCE_REPOS` | No | Comma-separated `org/repo=clone_url` for weekly maintenance |
### Local Development > **Important**: Do NOT set `ANTHROPIC_API_KEY` in the workspace — it takes priority over the OAuth token and will use pay-per-use credits instead of your Max subscription.
```bash ## Webhook Endpoints
npm install
cp k8s/secret.yaml.example .env # Edit with real values (use KEY=value format)
npm run dev
```
### Deploy to k3s | Endpoint | Purpose |
|----------|---------|
| `GET /health` | Health check |
| `GET /queue` | Queue status (pending, running, active workspaces) |
| `POST /webhook/gitea-issue-triage` | Route new issues to analyse or fix-bug |
| `POST /webhook/gitea-issue-label` | Stage transitions via labels |
| `POST /webhook/gitea-issue-comment` | Re-trigger analyst on new/edited comments |
| `POST /webhook/gitea-pr-review` | Trigger code review on PR open/update |
| `POST /webhook/gitea-pr-review-rework` | Route review outcomes to rework or test |
| `POST /webhook/gitea-release` | Handle release workflow |
| `POST /webhook/task-complete/:name` | Callback from workspaces when done |
```bash ## How Workspaces Work
# Build and push image
docker build -t registry.samson.media/sdlc-orchestrator:latest .
docker push registry.samson.media/sdlc-orchestrator:latest
# Create secrets Workspaces are **ephemeral** (emptyDir, no persistent storage):
cp k8s/secret.yaml.example k8s/secret.yaml
# Edit k8s/secret.yaml with real values
kubectl apply -f k8s/secret.yaml
# Deploy 1. Orchestrator creates workspace via Coder API with parameters (issue number, task type, tokens, callback URL)
kubectl apply -f k8s/deployment.yaml 2. Startup script clones the repo and checks out `develop`
``` 3. For heavy stages (develop, test, review, rework, fix-bug, devops): installs deps, builds frontend, installs Playwright
4. For lightweight stages (analyse, architect, release, maintenance): skips build
5. Reads `.claude/commands/<task_type>.md`, strips YAML frontmatter, substitutes `$ARGUMENTS` with issue number
6. Runs `claude -p --dangerously-skip-permissions --verbose "<prompt>"`
7. On completion (success or failure via ERR/EXIT trap): POSTs to callback URL
8. Orchestrator receives callback, deletes workspace via Coder API
### Configure Gitea Webhooks ## Resilience
For each project, add these webhooks in **Settings → Webhooks**: - **Deduplication**: Tasks keyed by `{taskType}-{repo}-{issue}`. Duplicate webhooks are dropped.
- **Startup reconciliation**: On boot, queries Coder for running workspaces and re-adopts them into the queue.
- **409 handling**: If workspace already exists — adopts if running, deletes and retries if stopped/failed.
- **Callback trap**: Startup script uses `trap` to always fire the callback, even on clone failure or other errors.
- **TTL safety net**: Coder template has 1-hour auto-stop as a backstop for missed callbacks.
| Event | URL | ## CI/CD
|-------|-----|
| Issues (opened) | `https://sdlc.samson.media/webhook/gitea-issue-triage` |
| Issues (labeled) | `https://sdlc.samson.media/webhook/gitea-issue-label` |
| Issue Comments (created) | `https://sdlc.samson.media/webhook/gitea-issue-comment` |
| Pull Request (opened, synchronized) | `https://sdlc.samson.media/webhook/gitea-pr-review` |
| Pull Request Review (submitted) | `https://sdlc.samson.media/webhook/gitea-pr-review-rework` |
| Issues (opened, labeled) — releases | `https://sdlc.samson.media/webhook/gitea-release` |
## Coder Template | Workflow | Trigger | Output |
|----------|---------|--------|
| `publish.yml` | `v*` tags | `registry.samson.media/sdlc-orchestrator:<version>` + `:latest` |
| `publish-workspace.yml` | Changes to `coder/workspace.Dockerfile` on main | `registry.samson.media/coder-workspace:latest` |
The `coder/` directory contains the Terraform template for ephemeral Kubernetes workspaces. See `coder/README.md`. Fleet GitOps (in `samson-media/devops` repo) watches `registry.samson.media/sdlc-orchestrator:latest` for deployment.
## Migrating from n8n
1. Deploy this service to k3s
2. Update Gitea webhooks to point to `sdlc.samson.media` instead of `n8n.samson.media`
3. Verify with a test issue
4. Decommission n8n
+52 -110
View File
@@ -76,27 +76,6 @@ data "coder_parameter" "memory" {
} }
} }
data "coder_parameter" "disk_size" {
name = "disk_size"
display_name = "Disk Size (GB)"
description = "Persistent home directory size"
type = "number"
default = "10"
mutable = false
option {
name = "5 GB"
value = "5"
}
option {
name = "10 GB"
value = "10"
}
option {
name = "20 GB"
value = "20"
}
}
# ─── Automation Parameters ─────────────────────────────────────────────────── # ─── Automation Parameters ───────────────────────────────────────────────────
@@ -217,6 +196,15 @@ data "coder_parameter" "callback_url" {
mutable = false mutable = false
} }
data "coder_parameter" "claude_oauth_token" {
name = "claude_oauth_token"
display_name = "Claude OAuth Token"
description = "OAuth token for Claude Code Max subscription"
type = "string"
default = ""
mutable = false
}
data "coder_parameter" "deploy_env" { data "coder_parameter" "deploy_env" {
name = "deploy_env" name = "deploy_env"
display_name = "Deploy Environment" display_name = "Deploy Environment"
@@ -375,33 +363,6 @@ resource "kubernetes_resource_quota_v1" "workspace" {
"limits.cpu" = "${tonumber(data.coder_parameter.cpu.value) * 1000}m" "limits.cpu" = "${tonumber(data.coder_parameter.cpu.value) * 1000}m"
"requests.memory" = "${tonumber(data.coder_parameter.memory.value) / 2}Mi" "requests.memory" = "${tonumber(data.coder_parameter.memory.value) / 2}Mi"
"limits.memory" = "${data.coder_parameter.memory.value}Mi" "limits.memory" = "${data.coder_parameter.memory.value}Mi"
"requests.storage" = "${data.coder_parameter.disk_size.value}Gi"
"persistentvolumeclaims" = "1"
}
}
}
# =============================================================================
# Storage
# =============================================================================
resource "kubernetes_persistent_volume_claim_v1" "home" {
metadata {
name = "home"
namespace = kubernetes_namespace_v1.workspace.metadata[0].name
labels = local.labels
}
wait_until_bound = false
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "longhorn-backup"
resources {
requests = {
storage = "${data.coder_parameter.disk_size.value}Gi"
}
} }
} }
} }
@@ -462,7 +423,7 @@ resource "coder_agent" "main" {
} }
env = { env = {
ANTHROPIC_API_KEY = data.coder_parameter.anthropic_api_key.value CLAUDE_CODE_OAUTH_TOKEN = data.coder_parameter.claude_oauth_token.value
GITHUB_TOKEN = data.coder_external_auth.github.access_token GITHUB_TOKEN = data.coder_external_auth.github.access_token
GITEA_TOKEN = data.coder_parameter.gitea_token.value GITEA_TOKEN = data.coder_parameter.gitea_token.value
GITEA_ORG = data.coder_parameter.gitea_org.value GITEA_ORG = data.coder_parameter.gitea_org.value
@@ -476,6 +437,17 @@ resource "coder_agent" "main" {
startup_script = <<-EOT startup_script = <<-EOT
#!/bin/bash #!/bin/bash
# Always notify the orchestrator when done, even on failure
notify_complete() {
if [ -n "$CALLBACK_URL" ]; then
curl -s -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\"}" \
"$CALLBACK_URL" || echo "Callback failed (non-fatal)"
fi
}
trap 'notify_complete "failed"' ERR EXIT
set -e set -e
# --- Configure git --- # --- Configure git ---
@@ -490,27 +462,39 @@ resource "coder_agent" "main" {
fi fi
# --- Clone repository --- # --- Clone repository ---
if [ -n "$REPO_CLONE_URL" ] && [ ! -d ~/project ]; then if [ -n "$REPO_CLONE_URL" ]; then
git clone "$REPO_CLONE_URL" ~/project git clone "$REPO_CLONE_URL" ~/project
cd ~/project cd ~/project
# Switch to develop branch if it exists # Switch to develop branch if it exists
git fetch origin develop 2>/dev/null && git checkout develop 2>/dev/null || true git fetch origin develop 2>/dev/null && git checkout develop 2>/dev/null || true
# Install backend deps # Lightweight stages only need the code, not a full build
npm ci --legacy-peer-deps LIGHT_STAGES="analyse architect release maintenance"
if echo "$LIGHT_STAGES" | grep -qw "$TASK_TYPE"; then
echo "Lightweight stage ($TASK_TYPE) — skipping build"
else
# Install deps if package.json exists
if [ -f package.json ]; then
npm ci --legacy-peer-deps || npm ci
fi
# Install frontend deps # Install frontend deps if present
if [ -f frontend/package.json ]; then
cd frontend && npm ci && cd .. cd frontend && npm ci && cd ..
npm run build:frontend 2>/dev/null || true
fi
# Build frontend (required — vitest fails without frontend/dist) # Install Playwright if needed
npm run build:frontend if grep -q "playwright" package.json 2>/dev/null; then
# Install Playwright Chromium
npx playwright install chromium npx playwright install chromium
fi
# Apply local migrations # Apply local migrations if script exists
npm run db:migrate:local if npm run --silent db:migrate:local 2>/dev/null; then
echo "Local migrations applied"
fi
fi
fi fi
# --- Automated task execution --- # --- Automated task execution ---
@@ -525,25 +509,18 @@ resource "coder_agent" "main" {
# Read the slash command .md file, strip YAML frontmatter, substitute $ARGUMENTS # Read the slash command .md file, strip YAML frontmatter, substitute $ARGUMENTS
CMD_FILE=".claude/commands/$TASK_TYPE.md" CMD_FILE=".claude/commands/$TASK_TYPE.md"
if [ ! -f "$CMD_FILE" ]; then if [ ! -f "$CMD_FILE" ]; then
echo "ERROR: Command file not found: $CMD_FILE" | tee ~/task-output.log echo "ERROR: Command file not found: $CMD_FILE"
exit 1 exit 1
fi fi
PROMPT=$(sed '1{/^---$/!q}; 1,/^---$/d' "$CMD_FILE" | sed "s/\\\$ARGUMENTS/$ARGS/g") PROMPT=$(sed '1{/^---$/!q}; 1,/^---$/d' "$CMD_FILE" | sed "s/\\\$ARGUMENTS/$ARGS/g")
# Run Claude Code in non-interactive mode with tool access # Run Claude Code in non-interactive mode with tool access
claude -p --dangerously-skip-permissions --verbose "$PROMPT" 2>&1 | tee ~/task-output.log claude -p --dangerously-skip-permissions --verbose "$PROMPT"
EXIT_CODE=$?
echo "Claude exited with code: $EXIT_CODE" | tee -a ~/task-output.log
echo "Task completed. Output saved to ~/task-output.log"
# Notify orchestrator that the task is done
if [ -n "$CALLBACK_URL" ]; then
curl -s -X POST -H "Content-Type: application/json" \
-d '{"status":"complete"}' \
"$CALLBACK_URL" || echo "Callback failed (non-fatal)"
fi
fi fi
# Success — override the trap
trap - ERR EXIT
notify_complete "complete"
EOT EOT
} }
@@ -592,39 +569,6 @@ resource "kubernetes_deployment_v1" "workspace" {
name = kubernetes_secret_v1.registry.metadata[0].name name = kubernetes_secret_v1.registry.metadata[0].name
} }
init_container {
name = "fix-permissions"
image = "busybox:latest"
command = ["sh", "-c", <<-EOC
if [ ! -d /home/coder/project ]; then
mkdir -p /home/coder/project
fi
chown -R 1000:1000 /home/coder
EOC
]
volume_mount {
name = "home"
mount_path = "/home/coder"
}
resources {
requests = {
cpu = "5m"
memory = "8Mi"
}
limits = {
cpu = "50m"
memory = "32Mi"
}
}
security_context {
run_as_user = 0
}
}
container { container {
name = "coder-agent" name = "coder-agent"
image = "registry.samson.media/coder-workspace:latest" image = "registry.samson.media/coder-workspace:latest"
@@ -637,8 +581,8 @@ resource "kubernetes_deployment_v1" "workspace" {
} }
env { env {
name = "ANTHROPIC_API_KEY" name = "CLAUDE_CODE_OAUTH_TOKEN"
value = data.coder_parameter.anthropic_api_key.value value = data.coder_parameter.claude_oauth_token.value
} }
env { env {
@@ -710,9 +654,7 @@ resource "kubernetes_deployment_v1" "workspace" {
volume { volume {
name = "home" name = "home"
persistent_volume_claim { empty_dir {}
claim_name = kubernetes_persistent_volume_claim_v1.home.metadata[0].name
}
} }
} }
} }
+5 -2
View File
@@ -11,8 +11,10 @@ export interface Config {
giteaDevToken: string; giteaDevToken: string;
/** Gitea API token for the review bot account */ /** Gitea API token for the review bot account */
giteaReviewToken: string; giteaReviewToken: string;
/** Anthropic API key passed to Claude Code in workspaces */ /** Anthropic API key passed to Claude Code in workspaces (fallback) */
anthropicApiKey: string; anthropicApiKey: string;
/** Claude Code OAuth token for Max subscription (preferred) */
claudeOauthToken: string;
/** Username of the dev bot (to filter out self-replies) */ /** Username of the dev bot (to filter out self-replies) */
botDevUsername: string; botDevUsername: string;
/** Username of the review bot (to filter out self-replies) */ /** Username of the review bot (to filter out self-replies) */
@@ -41,7 +43,8 @@ export function loadConfig(): Config {
coderTemplateId: required("CODER_TEMPLATE_ID"), coderTemplateId: required("CODER_TEMPLATE_ID"),
giteaDevToken: required("GITEA_DEV_TOKEN"), giteaDevToken: required("GITEA_DEV_TOKEN"),
giteaReviewToken: required("GITEA_REVIEW_TOKEN"), giteaReviewToken: required("GITEA_REVIEW_TOKEN"),
anthropicApiKey: required("ANTHROPIC_API_KEY"), anthropicApiKey: process.env.ANTHROPIC_API_KEY || "",
claudeOauthToken: process.env.CLAUDE_OAUTH_TOKEN || "",
botDevUsername: process.env.BOT_DEV_USERNAME || "claude-dev", botDevUsername: process.env.BOT_DEV_USERNAME || "claude-dev",
botReviewUsername: process.env.BOT_REVIEW_USERNAME || "claude-review", botReviewUsername: process.env.BOT_REVIEW_USERNAME || "claude-review",
giteaUrl: process.env.GITEA_URL || "https://gitea.samson.media", giteaUrl: process.env.GITEA_URL || "https://gitea.samson.media",
+5 -1
View File
@@ -127,10 +127,14 @@ export class TaskQueue {
this.active.delete(workspaceName); this.active.delete(workspaceName);
try { try {
await this.coder.stopWorkspace(entry.workspaceId);
console.log(`[queue] workspace stopped: ${workspaceName} — waiting for shutdown`);
// Wait for the workspace to fully stop before deleting
await new Promise((resolve) => setTimeout(resolve, 10_000));
await this.coder.deleteWorkspace(entry.workspaceId); await this.coder.deleteWorkspace(entry.workspaceId);
console.log(`[queue] workspace deleted: ${workspaceName}`); console.log(`[queue] workspace deleted: ${workspaceName}`);
} catch (err) { } catch (err) {
console.error(`[queue] failed to delete workspace ${workspaceName}:`, err); console.error(`[queue] failed to clean up workspace ${workspaceName}:`, err);
} }
// Drain in case pending tasks were waiting for capacity // Drain in case pending tasks were waiting for capacity
+3
View File
@@ -7,6 +7,7 @@ export class CoderClient {
private token: string; private token: string;
private templateId: string; private templateId: string;
private anthropicApiKey: string; private anthropicApiKey: string;
private claudeOauthToken: string;
private callbackUrl: string; private callbackUrl: string;
constructor(config: Config) { constructor(config: Config) {
@@ -14,6 +15,7 @@ export class CoderClient {
this.token = config.coderToken; this.token = config.coderToken;
this.templateId = config.coderTemplateId; this.templateId = config.coderTemplateId;
this.anthropicApiKey = config.anthropicApiKey; this.anthropicApiKey = config.anthropicApiKey;
this.claudeOauthToken = config.claudeOauthToken;
this.callbackUrl = config.callbackUrl; this.callbackUrl = config.callbackUrl;
} }
@@ -31,6 +33,7 @@ export class CoderClient {
{ name: "gitea_org", value: task.giteaOrg }, { name: "gitea_org", value: task.giteaOrg },
{ name: "gitea_repo", value: task.giteaRepo }, { name: "gitea_repo", value: task.giteaRepo },
{ name: "anthropic_api_key", value: this.anthropicApiKey }, { name: "anthropic_api_key", value: this.anthropicApiKey },
{ name: "claude_oauth_token", value: this.claudeOauthToken },
{ name: "callback_url", value: `${this.callbackUrl}/webhook/task-complete/${name}` }, { name: "callback_url", value: `${this.callbackUrl}/webhook/task-complete/${name}` },
...(task.deployEnv ...(task.deployEnv
? [{ name: "deploy_env", value: task.deployEnv }] ? [{ name: "deploy_env", value: task.deployEnv }]