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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
32 changes: 32 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Keep the build context small and make sure no local secret or build artefact is ever baked into an image.
.git
.github
node_modules
.next
out
build
dist
coverage

# Secrets: .env.example / .env.test.example are documentation and stay out too — the image is configured
# entirely through the environment.
.env
.env.*

# Local tooling and editor state
.claude
.vscode
.idea
.DS_Store
*.tsbuildinfo
npm-debug.log*

# Not needed to build or run the image
tests
docs
README.md
CLAUDE.md
components.json
Dockerfile
docker-compose.yml
.dockerignore
104 changes: 87 additions & 17 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,32 +1,102 @@
# ── Required ────────────────────────────────────────────────────────────────
# Local Postgres (Homebrew): createdb ai_staffing_agency
# ═══════════════════════════════════════════════════════════════════════════════
# AI Staffing Agency — environment
#
# cp .env.example .env then fill in the three REQUIRED values below.
#
# Every variable here is validated at boot by src/server/env.ts: a bad value stops
# the worker immediately and, in production, stops the web process too. Defaults in
# brackets are what src/server/config.ts uses when the variable is unset.
# Deployment guidance: docs/DEPLOYMENT.md · Security: docs/SECURITY.md
# ═══════════════════════════════════════════════════════════════════════════════

# ── REQUIRED ────────────────────────────────────────────────────────────────────
# PostgreSQL 14+. Local (Homebrew): createdb ai_staffing_agency
# Production: add sslmode=require (or verify-full) and connection_limit — see DEPLOYMENT.md § Pooling.
DATABASE_URL="postgresql://YOUR_USER@localhost:5432/ai_staffing_agency?schema=public&options=-c%20TimeZone%3DUTC"
# openssl rand -base64 32

# Session signing key. Generate one — an empty value fails boot: openssl rand -base64 32
# Rotating it signs every user out. Put the old value in AUTH_SECRET_PREVIOUS for a grace period.
AUTH_SECRET=""
AUTH_TRUST_HOST=true
# openssl rand -base64 32 (AES-256-GCM key for the credentials vault)

# AES-256-GCM key for the org credentials vault. Must be base64 that decodes to exactly 32 bytes:
# openssl rand -base64 32
# WARNING: losing or rotating this key makes every stored customer credential unreadable. Escrow it
# separately from your database backups. See docs/DEPLOYMENT.md § Key rotation.
CREDENTIAL_ENCRYPTION_KEY=""

# ── Optional: model providers ───────────────────────────────────────────────
# With no keys set the app runs in clearly-badged "Simulated mode" using a deterministic mock provider.
# ── URLs and proxies ────────────────────────────────────────────────────────────
# Public origin. REQUIRED in production and must be https. NEXTAUTH_URL is accepted as an alias.
# AUTH_URL="https://app.example.com"
# Only set when a proxy in front of the app rewrites Host/X-Forwarded-*; AUTH_URL alone is safer.
AUTH_TRUST_HOST=true
# Number of reverse proxies whose X-Forwarded-For entries are trusted for rate limiting [1 in prod, 0 in dev].
# TRUSTED_PROXY_HOPS=1
# Extra hosts allowed to post Server Actions (read by next.config.ts) — comma separated, host only.
# SERVER_ACTIONS_ALLOWED_ORIGINS="app.example.com"
# Must be identical across web instances and builds, or Server Action payloads fail to decrypt:
# openssl rand -base64 32
# NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=""

# ── Key rotation (optional, both accept the previous value during a rollout) ─────
# AUTH_SECRET_PREVIOUS=""
# CREDENTIAL_ENCRYPTION_KEY_PREVIOUS=""

# ── Executor / worker ───────────────────────────────────────────────────────────
# Runs are always executed by the separate worker process (`npm run worker`).
# "inline" only tells `npm run dev` to start a worker alongside the web server; "off" = a dedicated
# `npm run worker` process does (production default). src/worker.ts ignores this and always executes.
# EXECUTOR_MODE=inline
# EXECUTOR_DISABLED=true # deprecated alias for EXECUTOR_MODE=off
# EXECUTOR_POLL_MS=1000 # queue poll interval [1000]
# EXECUTOR_CONCURRENCY=2 # runs in flight per executor process [2]
# EXECUTOR_STALE_LOCK_MS=120000 # a RUNNING run with no heartbeat for this long is recovered [120000]
# SCHEDULER_TICK_MS=30000 # how often due scheduled workers are queued [30000]
# How long a stopping worker waits for in-flight runs before handing their leases back [20000].
# Keep it BELOW the orchestrator's termination grace period (Docker/K8s default 30 s).
# SHUTDOWN_GRACE_MS=20000
# SERVICE_NAME=worker # logger field distinguishing web from worker [web]

# ── Sign-up and demo access ─────────────────────────────────────────────────────
# open = anyone can create a workspace · invite = SIGNUP_INVITE_CODE required · closed = invitations only.
# Defaults to "open" in dev and "closed" in production.
# SIGNUP_MODE=closed
# SIGNUP_INVITE_CODE="" # ≥ 12 chars; required when SIGNUP_MODE=invite
# DEMO_MODE=true # pre-fills the demo credentials on the sign-in page
# Required before `npm run db:seed:demo` will touch a production database. The demo workspace
# ships a publicly documented password — never set this on a deployment with real customers.
# ALLOW_DEMO_SEED=true

# ── Model providers ─────────────────────────────────────────────────────────────
# With no keys set the app runs in clearly-badged "Simulated mode" on a deterministic mock provider.
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=

# Override which model serves each tier: "<provider>:<model-id>"
# MODEL_TIER_FAST=anthropic:claude-haiku-4-5
# MODEL_TIER_STANDARD=anthropic:claude-sonnet-5
# MODEL_TIER_REASONING=anthropic:claude-opus-5

# Force the mock provider even when keys exist (useful for demos/tests)
# Force the mock provider even when keys exist (demos, load tests).
# FORCE_SIMULATED=true

# ── Optional: tools ─────────────────────────────────────────────────────────
# Real web search (https://tavily.com). Without it, web_search returns realistic simulated results.
# ── Tools ───────────────────────────────────────────────────────────────────────
# Real web search (https://tavily.com). Without it, web_search returns simulated results.
TAVILY_API_KEY=

# ── Optional: runtime ───────────────────────────────────────────────────────
# EXECUTOR_DISABLED=true # don't start the in-process executor/scheduler
# EXECUTOR_POLL_MS=1000
# EXECUTOR_CONCURRENCY=2
# USAGE_MARGIN_MULTIPLIER=1.4 # billable = cost × multiplier
# ── Limits and spend ────────────────────────────────────────────────────────────
# PLATFORM_DEFAULT_MONTHLY_BUDGET_USD=25 # per org, when Organization.monthlyBudgetUsd is null [25]
# PLATFORM_MAX_COST_PER_RUN_USD=5 # ceiling a blueprint's own limit is clamped to [5]
# PLATFORM_MAX_TOOL_CALLS_PER_RUN=60 # [60]
# PLATFORM_MAX_RUN_DURATION_SEC=1800 # [1800]
# USAGE_MARGIN_MULTIPLIER=1.4 # billable = cost × multiplier [1.4]
# Development escape hatch only — rejected at boot when NODE_ENV=production.
# RATE_LIMIT_DISABLED=true

# ── Retention (enforced by the worker's maintenance sweep) ──────────────────────
# RETENTION_TRACE_DAYS=30 # clear model prompts/responses and step payloads after N days [30]
# RETENTION_EVENTS_DAYS=365 # delete activity + security events after N days [365]

# ── Observability ───────────────────────────────────────────────────────────────
# LOG_LEVEL=info # debug | info | warn | error [info]
# LOG_FORMAT=json # json | pretty [json in production, pretty otherwise]
# APP_VERSION= # git sha baked in at image build; reported by /api/health
# PRISMA_LOG=query # NEVER in production: logs SQL including parameters
17 changes: 14 additions & 3 deletions .env.test.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# Copy to .env.test for `npm test`. Tests always run in Simulated mode against a dedicated database:
# createdb ai_staffing_agency_test
# tests/setup/env.ts additionally forces FORCE_SIMULATED, EXECUTOR_MODE=off and RATE_LIMIT_DISABLED,
# and clears every provider key, so a suite can never reach a real API or execute a run in the background.
# Keep this file in sync with .env.example and with .github/workflows/ci.yml.

# Must contain "_test": tests/setup/global-setup.ts refuses to run migrations against anything else.
DATABASE_URL="postgresql://YOUR_USER@localhost:5432/ai_staffing_agency_test?schema=public&options=-c%20TimeZone%3DUTC"
AUTH_SECRET="test-secret-not-used-in-production"

AUTH_SECRET="test-secret-not-used-in-production-0123456789"
AUTH_TRUST_HOST=true
# openssl rand -base64 32
CREDENTIAL_ENCRYPTION_KEY=""

# Fixed throwaway key so the credentials-vault suites are reproducible. Test data only —
# generate a real one for any other environment: openssl rand -base64 32
CREDENTIAL_ENCRYPTION_KEY="dGVzdC1vbmx5LWtleS1kby1ub3QtdXNlLWluLXByb2Q="

# Runs are executed by calling executeRun() directly; no background loop in tests.
EXECUTOR_MODE=off
EXECUTOR_DISABLED=true
121 changes: 121 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: ci

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
name: typecheck · lint · test · build
runs-on: ubuntu-latest
timeout-minutes: 30

services:
postgres:
image: postgres:16
env:
POSTGRES_USER: ci
POSTGRES_PASSWORD: ci
POSTGRES_DB: ai_staffing_agency_test
TZ: UTC
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U ci"
--health-interval 5s
--health-timeout 5s
--health-retries 12

env:
# Must contain "_test": tests/setup/global-setup.ts refuses any other database.
DATABASE_URL: postgresql://ci:ci@localhost:5432/ai_staffing_agency_test?schema=public&options=-c%20TimeZone%3DUTC
SHADOW_DATABASE_URL: postgresql://ci:ci@localhost:5432/ai_staffing_agency_shadow?schema=public
AUTH_SECRET: ci-only-secret-0123456789abcdef0123456789abcdef
AUTH_URL: http://localhost:3000
AUTH_TRUST_HOST: "true"
# Throwaway 32-byte key; the vault suites need a valid one. Never used outside CI.
CREDENTIAL_ENCRYPTION_KEY: dGVzdC1vbmx5LWtleS1kby1ub3QtdXNlLWluLXByb2Q=
EXECUTOR_MODE: "off"
FORCE_SIMULATED: "true"
NEXT_TELEMETRY_DISABLED: "1"
TZ: UTC

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

# `npm ci` runs `prisma generate` via postinstall.
- run: npm ci

- name: Apply migrations to the test database
run: npx prisma migrate deploy

- name: Fail if schema.prisma has drifted from prisma/migrations
run: |
PGPASSWORD=ci createdb -h localhost -U ci ai_staffing_agency_shadow
npx prisma migrate diff \
--from-migrations prisma/migrations \
--to-schema-datamodel prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL" \
--exit-code

- name: Fail if the migration manifest is stale
run: |
npm run migrations:manifest
git diff --exit-code -- src/generated/migrations.json

- run: npx tsc --noEmit -p .

- run: npx eslint .

- run: npx vitest run

- name: Cache the Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: next-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*.[jt]s', 'src/**/*.[jt]sx') }}
restore-keys: |
next-${{ hashFiles('package-lock.json') }}-

- name: Build the web app
run: npm run build
env:
NODE_ENV: production
APP_VERSION: ${{ github.sha }}

- name: Build the worker bundle
run: npm run build:worker

- name: Audit production dependencies
run: npm run audit:prod

image:
name: docker image
needs: verify
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# Build only: publishing is deliberately left to the deploy workflow that owns the registry credentials.
- uses: docker/build-push-action@v6
with:
context: .
target: runner
push: false
build-args: APP_VERSION=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ next-env.d.ts

# local tooling
.claude/

# worker bundle (npm run build:worker)
/dist
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Product:** the staffing agency for AI workers. A company describes a job in plain English → the platform scopes it (JobSpec) → designs an AI worker (WorkerBlueprint) → the user *hires* it → it executes runs → produces deliverables → is evaluated → can be talked to, improved, or *replaced* like a contractor. Core loop: **Job → Worker → Runs → Deliverables → Evaluation → Replace.**

**Read `docs/CONTRACTS.md` before writing any code.** It defines module boundaries, public function signatures, and file ownership. The typed contracts live in code: `prisma/schema.prisma`, `src/server/domain/*`, `src/server/*/types.ts`.
**Read `docs/CONTRACTS.md` before writing any code.** For the production/security/redesign phase also read `docs/PRODUCTION.md` (contracts + file ownership that override the FROZEN list for assigned files) and `docs/DESIGN.md` (the design system every UI change follows). It defines module boundaries, public function signatures, and file ownership. The typed contracts live in code: `prisma/schema.prisma`, `src/server/domain/*`, `src/server/*/types.ts`.

## Stack (pinned — do not upgrade or swap)
Next.js 15 App Router · React 19 · strict TypeScript · Tailwind v4 · shadcn/ui (radix, components already in `src/components/ui`) · Prisma 6 + PostgreSQL 14 · Auth.js v5 (credentials) · Vercel AI SDK **v5** (`ai@5`, `@ai-sdk/*@2`) · Zod **v4** · Vitest · lucide-react · recharts · date-fns · sonner.
Expand Down
73 changes: 73 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# syntax=docker/dockerfile:1.7
#
# One image, three roles (docs/DEPLOYMENT.md):
# web → node server.js (default CMD; Next.js standalone server)
# worker → node dist/worker.cjs (the run executor)
# migrate → npx prisma migrate deploy (one-shot release job, run before rolling web/worker)
#
# Debian slim rather than Alpine: Prisma's query engine ships a glibc/OpenSSL 3 binary, so this avoids adding a
# musl binaryTarget to the frozen schema.

# ── base ────────────────────────────────────────────────────────────────────────
FROM node:22-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

# ── deps: full dependency tree for building ─────────────────────────────────────
FROM base AS deps
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN --mount=type=cache,target=/root/.npm npm ci

# ── build: next build + the worker bundle ───────────────────────────────────────
FROM deps AS build
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
COPY . .
# The migration manifest (npm prebuild) is what /api/ready compares against the database.
RUN npx prisma generate \
&& npm run build \
&& npm run build:worker

# ── prod-deps: runtime dependency tree (no devDependencies) ─────────────────────
# The worker bundle and the prisma CLI both resolve from here; the standalone server overlays its traced copies.
FROM base AS prod-deps
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npx prisma generate

# ── runner ──────────────────────────────────────────────────────────────────────
FROM base AS runner
ARG APP_VERSION=dev
ENV NODE_ENV=production \
APP_VERSION=$APP_VERSION \
PORT=3000 \
HOSTNAME=0.0.0.0 \
TZ=UTC \
SERVICE_NAME=web \
EXECUTOR_MODE=off

COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/dist ./dist
# schema + migrations for the release job
COPY --from=build --chown=node:node /app/prisma ./prisma

# Next writes its ISR/image cache here.
RUN mkdir -p .next/cache && chown -R node:node .next

USER node
EXPOSE 3000
STOPSIGNAL SIGTERM

# Liveness only: /api/health touches no database, so a probe never queues behind a slow query.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["node", "server.js"]
Loading
Loading