Skip to content
Draft
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
15 changes: 13 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@
.git
.vscode
.idea
.claude

# external dependencies
node_modules
**/node_modules

# docker files
docker-compose*.yml
**/Dockerfile*

# build artifacts
dist/
**/dist
coverage/
**/coverage

# not needed files
README.md
tools/
!tools/deployment/nginx
.gitignore
.env
coverage/

# env files hold secrets — never in a build context
**/.env
**/.env.*
!**/.env.example
102 changes: 102 additions & 0 deletions .github/workflows/deploy-ai-studio.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Deploy AI Studio

on:
push:
branches: ["WB-229-swarm-alignment"]
workflow_dispatch:
inputs:
image_tag:
description: 'Image tag (defaults to short SHA)'
required: false

permissions:
id-token: write
contents: read

env:
REGISTRY: synergycodes.azurecr.io
APP: wb-ai-studio

jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.tag.outputs.value }}

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Resolve image tag
id: tag
run: |
TAG="${{ inputs.image_tag || github.sha }}"
echo "value=${TAG::7}" >> "$GITHUB_OUTPUT"

- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Log in to ACR
run: az acr login --name synergycodes

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and push runtime image
uses: docker/build-push-action@v6
with:
context: .
file: deploy/ai-studio/Dockerfile
target: runtime
push: true
tags: |
${{ env.REGISTRY }}/${{ env.APP }}:${{ steps.tag.outputs.value }}-runtime
${{ env.REGISTRY }}/${{ env.APP }}:latest-runtime
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP }}:latest-runtime
cache-to: type=inline

- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: deploy/ai-studio/Dockerfile
target: web
push: true
tags: |
${{ env.REGISTRY }}/${{ env.APP }}:${{ steps.tag.outputs.value }}-web
${{ env.REGISTRY }}/${{ env.APP }}:latest-web
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP }}:latest-web
cache-to: type=inline
build-args: |
VITE_BACKEND_URL=

deploy:
runs-on: ubuntu-latest
needs: build-and-push

steps:
- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Install Azure SSH extension
run: az extension add --name ssh --yes

- name: Refresh docker compose on Azure VM
run: |
az ssh vm \
--name ${{ vars.AI_STUDIO_VM_NAME }} \
--resource-group ${{ vars.AI_STUDIO_VM_RG }} \
--command "
set -e
az acr login --name synergycodes
docker compose -f ${{ secrets.VM_DEPLOY_DIR }}/docker-compose.yml pull
docker compose -f ${{ secrets.VM_DEPLOY_DIR }}/docker-compose.yml up -d --no-build --force-recreate
"
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Three onboarding paths (A, B local-run; C docs-only). README "Get started" is th
| `pnpm preflight` | both | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents |
| `pnpm dev` / `pnpm dev:demo` | A | Demo (UI only, port 4200). No backend, no Docker |
| `pnpm infra:up` | B | Start Postgres + Temporal in Docker. Required before backend/worker |
| `pnpm -F backend db:migrate` | B | Apply Drizzle migrations. First run, or after schema changes |
| `pnpm -F backend db:migrate` | B | Apply Drizzle migrations out-of-band (backend also auto-migrates on boot) |
| `pnpm dev:ai-studio` | B | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) |
| `pnpm dev:backend` | B | Backend only (debug). Needs infra up |
| `pnpm dev:worker` | B | Execution worker only (debug). Needs infra up |
Expand All @@ -22,7 +22,7 @@ Three onboarding paths (A, B local-run; C docs-only). README "Get started" is th
| `pnpm test` | - | Run tests in `packages/sdk` and `packages/execution-core` |
| `pnpm check` | - | Lint + typecheck + format + knip |

Path A is UI-only and does not need Docker. Path B requires `pnpm infra:up` before backend/worker can start, and `db:migrate` on the first run.
Path A is UI-only and does not need Docker. Path B requires `pnpm infra:up` before backend/worker can start; the backend applies pending migrations automatically at boot.

### Agent signals

Expand All @@ -42,6 +42,9 @@ Long-running processes already emit stable log lines that scripts and agents can

```
tools/ - Root dev scripts: preflight, setup:env, infra wait
deployment/ - Swarm/Ansible deploy path mirroring the workflow-builder repo (ACR, Traefik)
deploy/
ai-studio/ - Production deployment: Dockerfile (runtime/web), compose, nginx, README
apps/
demo/ - Reference app consuming the SDK (React + Vite, port 4200)
ai-studio/ - Reference AI workflow product (React + Vite, port 4201)
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "tsx watch --env-file=.env ./src/server.ts",
"start": "tsx --env-file=.env ./src/server.ts",
"start:prod": "tsx ./src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint",
"lint:fix": "eslint --fix",
Expand All @@ -24,6 +25,7 @@
"drizzle-orm": "^0.44.0",
"hono": "^4.7.0",
"postgres": "^3.4.5",
"tsx": "^4.19.3",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/src/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';

import { env } from '../env';

// Same SQL files as `pnpm db:migrate`. Concurrent backends would race the
// migrator — single replica assumed.
export async function runMigrations(): Promise<void> {
const migrationsFolder = fileURLToPath(new URL('../../drizzle', import.meta.url));
const sql = postgres(env.DATABASE_URL, { max: 1 });
try {
await migrate(drizzle(sql), { migrationsFolder });
} finally {
await sql.end();
}
}
4 changes: 4 additions & 0 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ export const env = {
HOST: envOr('HOST', '127.0.0.1'),
DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'),
TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'),
// 0 disables (dev default); the deploy compose sets both
RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')),
RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')),
TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true',
};
128 changes: 128 additions & 0 deletions apps/backend/src/middleware/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';

import { type RateLimitOptions, createRateLimitMiddleware } from './rate-limit';

const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * 60 * 1000;

function makeApp(overrides: Partial<RateLimitOptions> = {}) {
let timestamp = 0;
const app = new Hono();
app.use(
'/api/workflows/:id/execute',
createRateLimitMiddleware({
perMinute: 2,
perDay: 5,
trustProxy: true,
now: () => timestamp,
...overrides,
}),
);
app.post('/api/workflows/:id/execute', (c) => c.json({ ok: true }, 202));

return {
app,
advance(ms: number) {
timestamp += ms;
},
execute(ip = '203.0.113.7') {
return app.request('/api/workflows/wf-1/execute', {
method: 'POST',
headers: { 'x-forwarded-for': ip },
});
},
};
}

describe('createRateLimitMiddleware', () => {
it('allows requests under the limit', async () => {
const { execute } = makeApp();

const first = await execute();
const second = await execute();
expect(first.status).toBe(202);
expect(second.status).toBe(202);
});

it('rejects with 429 and Retry-After once the minute limit is hit', async () => {
const { execute, advance } = makeApp();

await execute();
await execute();
advance(10_000);

const response = await execute();
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBe('50');
expect(await response.json()).toMatchObject({ code: 'rate_limited', retryAfterSeconds: 50 });
});

it('tracks each IP independently', async () => {
const { execute } = makeApp();

await execute('203.0.113.7');
await execute('203.0.113.7');
const blocked = await execute('203.0.113.7');
const otherIp = await execute('198.51.100.9');
expect(blocked.status).toBe(429);
expect(otherIp.status).toBe(202);
});

it('resets the minute window after it elapses', async () => {
const { execute, advance } = makeApp();

await execute();
await execute();
const blocked = await execute();
expect(blocked.status).toBe(429);

advance(MINUTE_MS);
const allowedAgain = await execute();
expect(allowedAgain.status).toBe(202);
});

it('enforces the day limit across minute windows', async () => {
const { execute, advance } = makeApp();

for (let index = 0; index < 5; index++) {
const allowed = await execute();
expect(allowed.status).toBe(202);
advance(MINUTE_MS);
}

const response = await execute();
expect(response.status).toBe(429);
// 5 minutes into the day window -> retry once the remaining day elapses
expect(response.headers.get('Retry-After')).toBe(String((DAY_MS - 5 * MINUTE_MS) / 1000));
});

it('resets the day window after it elapses', async () => {
const { execute, advance } = makeApp({ perMinute: 0 });

for (let index = 0; index < 5; index++) {
await execute();
}
const blocked = await execute();
expect(blocked.status).toBe(429);

advance(DAY_MS);
const allowedAgain = await execute();
expect(allowedAgain.status).toBe(202);
});

it('uses the first X-Forwarded-For hop as the client identity', async () => {
const { app } = makeApp();

const request = (chain: string) =>
app.request('/api/workflows/wf-1/execute', {
method: 'POST',
headers: { 'x-forwarded-for': chain },
});

await request('203.0.113.7, 10.0.0.1');
await request('203.0.113.7, 10.0.0.2');
const blocked = await request('203.0.113.7, 10.0.0.3');
expect(blocked.status).toBe(429);
});
});
Loading
Loading