refactor(dev): share postgres across main and worktrees

This commit is contained in:
Jiayuan Zhang 2026-03-24 14:27:35 +08:00
parent 94c9b07bfb
commit 2c28c4cba2
16 changed files with 839 additions and 359 deletions

View file

@ -1,5 +1,4 @@
# Database
COMPOSE_PROJECT_NAME=super_multica
POSTGRES_DB=multica
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica

View file

@ -22,10 +22,10 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
```bash
# One-click setup & run
make setup # First-time: install deps, start DB, migrate
make seed # Optional: load example data
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop everything
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
@ -41,11 +41,10 @@ make test # Go tests
make sqlc # Regenerate sqlc code
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
make seed # Seed example data
# Infrastructure
docker compose up -d # Start PostgreSQL
docker compose down # Stop PostgreSQL
make db-up # Start shared PostgreSQL
make db-down # Stop shared PostgreSQL
```
## 4. Coding Rules
@ -69,7 +68,7 @@ docker compose down # Stop PostgreSQL
## 6. Testing Rules
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Use testcontainers or test database for DB tests.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
## 7. Commit Rules

471
LOCAL_DEVELOPMENT.md Normal file
View file

@ -0,0 +1,471 @@
# Local Development Guide
This guide documents the intended local development workflow for Multica.
It covers:
- first-time setup
- day-to-day development in the main checkout
- isolated worktree development
- the shared PostgreSQL model
- testing and verification
- troubleshooting and destructive reset options
## Development Model
Local development uses one shared PostgreSQL container and one database per checkout.
- the main checkout usually uses `.env` and `POSTGRES_DB=multica`
- each Git worktree uses its own `.env.worktree`
- every checkout connects to the same PostgreSQL host: `localhost:5432`
- isolation happens at the database level, not by starting a separate Docker Compose project
- backend and frontend ports are still unique per worktree
This keeps Docker simple while still isolating schema and data.
## Prerequisites
- Node.js `v20+`
- `pnpm` `v10.28+`
- Go `v1.26+`
- Docker
## Important Rules
- The main checkout should use `.env`.
- A worktree should use `.env.worktree`.
- Do not copy `.env` into a worktree directory.
Why:
- the current command flow prefers `.env` over `.env.worktree`
- if a worktree contains `.env`, it can accidentally point back to the main database
## Environment Files
### Main Checkout
Create `.env` once:
```bash
cp .env.example .env
```
By default, `.env` points to:
```bash
POSTGRES_DB=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
PORT=8080
FRONTEND_PORT=3000
```
### Worktree
Generate `.env.worktree` from inside the worktree:
```bash
make worktree-env
```
That generates values like:
```bash
POSTGRES_DB=multica_super_multica_702
POSTGRES_PORT=5432
PORT=18782
FRONTEND_PORT=13702
DATABASE_URL=postgres://multica:multica@localhost:5432/multica_super_multica_702?sslmode=disable
```
Notes:
- `POSTGRES_DB` is unique per worktree
- `POSTGRES_PORT` stays fixed at `5432`
- backend and frontend ports are derived from the worktree path hash
- `make worktree-env` refuses to overwrite an existing `.env.worktree`
To regenerate a worktree env file:
```bash
FORCE=1 make worktree-env
```
## First-Time Setup
### Main Checkout
From the main checkout:
```bash
cp .env.example .env
make setup-main
```
What `make setup-main` does:
- installs JavaScript dependencies with `pnpm install`
- ensures the shared PostgreSQL container is running
- creates the application database if it does not exist
- runs all migrations against that database
Start the app:
```bash
make start-main
```
Stop the app processes:
```bash
make stop-main
```
This does not stop PostgreSQL.
### Worktree
From the worktree directory:
```bash
make worktree-env
make setup-worktree
```
What `make setup-worktree` does:
- uses `.env.worktree`
- ensures the shared PostgreSQL container is running
- creates the worktree database if it does not exist
- runs migrations against the worktree database
Start the worktree app:
```bash
make start-worktree
```
Stop the worktree app processes:
```bash
make stop-worktree
```
## Recommended Daily Workflow
### Main Checkout
Use the main checkout when you want a stable local environment for `main`.
```bash
make start-main
make stop-main
make check-main
```
### Feature Worktree
Use a worktree when you want isolated data and separate app ports.
```bash
git worktree add ../super-multica-feature -b feat/my-change main
cd ../super-multica-feature
make worktree-env
make setup-worktree
make start-worktree
```
After that, day-to-day commands are:
```bash
make start-worktree
make stop-worktree
make check-worktree
```
## Running Main and Worktree at the Same Time
This is a first-class workflow.
Example:
- main checkout
- database: `multica`
- backend: `8080`
- frontend: `3000`
- worktree checkout
- database: `multica_super_multica_702`
- backend: generated worktree port such as `18782`
- frontend: generated worktree port such as `13702`
Both checkouts use:
- the same PostgreSQL container
- the same PostgreSQL port: `5432`
But they do not share application data, because each uses a different database.
## Command Reference
### Shared Infrastructure
Start the shared PostgreSQL container:
```bash
make db-up
```
Stop the shared PostgreSQL container:
```bash
make db-down
```
Important:
- `make db-down` stops the container but keeps the Docker volume
- your local databases are preserved
### App Lifecycle
Main checkout:
```bash
make setup-main
make start-main
make stop-main
make check-main
```
Worktree:
```bash
make worktree-env
make setup-worktree
make start-worktree
make stop-worktree
make check-worktree
```
Generic targets for the current checkout:
```bash
make setup
make start
make stop
make check
make dev
make test
make migrate-up
make migrate-down
```
These generic targets require a valid env file in the current directory.
## How Database Creation Works
Database creation is automatic.
The following commands all ensure the target database exists before they continue:
- `make setup`
- `make start`
- `make dev`
- `make test`
- `make migrate-up`
- `make migrate-down`
- `make check`
That logic lives in `scripts/ensure-postgres.sh`.
## Testing
Run all local checks:
```bash
make check-main
```
Or from a worktree:
```bash
make check-worktree
```
This runs:
1. TypeScript typecheck
2. TypeScript unit tests
3. Go tests
4. Playwright E2E tests
Notes:
- Go tests create their own fixture data
- E2E tests create their own workspace and issue fixtures
- the check flow starts backend/frontend only if they are not already running
## Local Codex Daemon
Run the local daemon:
```bash
make daemon
```
Normal flow:
1. start the daemon
2. open the pairing link it prints
3. choose the workspace in the browser
4. let the daemon register its local runtime
Debug shortcut:
- you can set `MULTICA_WORKSPACE_ID` in your env file
- this skips normal pairing
- treat it as a local shortcut, not the default workflow
## Troubleshooting
### Missing Env File
If you see:
```text
Missing env file: .env
```
or:
```text
Missing env file: .env.worktree
```
then create the expected env file first.
Main checkout:
```bash
cp .env.example .env
```
Worktree:
```bash
make worktree-env
```
### Check Which Database a Checkout Uses
Inspect the env file:
```bash
cat .env
cat .env.worktree
```
Look for:
- `POSTGRES_DB`
- `DATABASE_URL`
- `PORT`
- `FRONTEND_PORT`
### List All Local Databases in Shared PostgreSQL
```bash
docker compose exec -T postgres psql -U multica -d postgres -At -c "select datname from pg_database order by datname;"
```
### Worktree Is Accidentally Using the Main Database
Check whether the worktree contains `.env`.
It should not.
The safe worktree setup is:
```bash
make worktree-env
make setup-worktree
make start-worktree
```
### App Stops but PostgreSQL Keeps Running
That is expected.
- `make stop`
- `make stop-main`
- `make stop-worktree`
only stop backend/frontend processes.
To stop the shared PostgreSQL container:
```bash
make db-down
```
## Destructive Reset
If you want to stop PostgreSQL and keep your local databases:
```bash
make db-down
```
If you want to wipe all local PostgreSQL data for this repo:
```bash
docker compose down -v
```
Warning:
- this deletes the shared Docker volume
- this deletes the main database and every worktree database in that volume
- after that you must run `make setup-main` or `make setup-worktree` again
## Typical Flows
### Stable Main Environment
```bash
cp .env.example .env
make setup-main
make start-main
```
### Feature Worktree
```bash
git worktree add ../super-multica-feature -b feat/my-change main
cd ../super-multica-feature
make worktree-env
make setup-worktree
make start-worktree
```
### Return to a Previously Configured Worktree
```bash
cd ../super-multica-feature
make start-worktree
```
### Validate Before Pushing
Main checkout:
```bash
make check-main
```
Worktree:
```bash
make check-worktree
```

View file

@ -1,4 +1,4 @@
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree
.PHONY: dev daemon build test migrate-up migrate-down sqlc clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@ -20,47 +20,40 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
COMPOSE_PROJECT_NAME ?= super_multica
export
COMPOSE := docker compose --env-file $(ENV_FILE)
COMPOSE := docker compose
define REQUIRE_ENV
@if [ ! -f "$(ENV_FILE)" ]; then \
echo "Missing env file: $(ENV_FILE)"; \
echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."; \
exit 1; \
fi
endef
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations
setup:
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
pnpm install
@echo "==> Starting PostgreSQL..."
@if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
echo " PostgreSQL already running, skipping docker compose up."; \
else \
$(COMPOSE) up -d; \
echo "==> Waiting for PostgreSQL to be ready..."; \
until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
sleep 1; \
done; \
fi
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Setup complete! Run 'make seed' if you want example data, then 'make start' to launch the app."
@echo "✓ Setup complete! Run 'make start' to launch the app."
# Start all services (backend + frontend)
start:
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
@if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \
echo "PostgreSQL already running, skipping docker compose up."; \
else \
$(COMPOSE) up -d; \
until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \
sleep 1; \
done; \
fi
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run ./cmd/server) & \
@ -69,15 +62,22 @@ start:
# Stop all services
stop:
$(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
$(COMPOSE) down
@echo "✓ All services stopped."
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@bash scripts/check.sh
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up:
@$(COMPOSE) up -d postgres
db-down:
@$(COMPOSE) down
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
@ -110,6 +110,8 @@ check-worktree:
# Go server
dev:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon:
@ -120,21 +122,24 @@ build:
cd server && go build -o bin/daemon ./cmd/daemon
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go test ./...
# Database
migrate-up:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc:
cd server && sqlc generate
seed:
cd server && go run ./cmd/seed
# Cleanup
clean:
rm -rf server/bin server/tmp

View file

@ -2,6 +2,8 @@
AI-native task management platform — like Linear, but with AI agents as first-class citizens.
For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md).
## Prerequisites
- [Node.js](https://nodejs.org/) (v20+)
@ -18,19 +20,16 @@ pnpm install
# 2. Copy environment variables for the shared main environment
cp .env.example .env
# 3. One-time setup: start DB and run migrations
# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
make setup
# 4. Optional: load example data
make seed
# 5. Start backend + frontend
# 4. Start backend + frontend
make start
```
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
Default behavior now prefers the shared main environment in `.env`. If you want an isolated environment for a Git worktree, generate `.env.worktree` and use the explicit worktree targets:
Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
```bash
make worktree-env
@ -38,13 +37,19 @@ make setup-worktree
make start-worktree
```
This lets you keep `.env` connected to your main database while using `.env.worktree` only for isolated feature testing.
Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level:
- `.env` typically uses `POSTGRES_DB=multica`
- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_super_multica_702`
- backend/frontend ports still stay unique per worktree
That keeps one Docker container and one volume, while still isolating schema and data per worktree.
## Project Structure
```
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
│ ├── cmd/ # server, daemon, migrate, seed
│ ├── cmd/ # server, daemon, migrate
│ ├── internal/ # Core business logic
│ ├── migrations/ # SQL migrations
│ └── sqlc.yaml # sqlc config
@ -87,11 +92,10 @@ This lets you keep `.env` connected to your main database while using `.env.work
| Command | Description |
|---------|-------------|
| `docker compose up -d` | Start PostgreSQL |
| `docker compose down` | Stop PostgreSQL |
| `make migrate-up` | Run database migrations |
| `make migrate-down` | Rollback database migrations |
| `make seed` | Seed example data |
| `make db-up` | Start the shared PostgreSQL container |
| `make db-down` | Stop the shared PostgreSQL container |
| `make migrate-up` | Ensure the current DB exists, then run migrations |
| `make migrate-down` | Rollback database migrations for the current DB |
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
@ -101,8 +105,8 @@ This lets you keep `.env` connected to your main database while using `.env.work
See [`.env.example`](.env.example) for all available variables:
- `DATABASE_URL` — PostgreSQL connection string
- `COMPOSE_PROJECT_NAME` — Docker Compose project name
- `POSTGRES_DB` / `POSTGRES_PORT` — Per-worktree PostgreSQL database and host port
- `POSTGRES_DB` — Database name for the current checkout or worktree
- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`)
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
@ -130,3 +134,9 @@ The local daemon currently supports one local runtime type: `codex`.
8. The daemon claims the task, runs `codex exec`, and reports the final comment back to the issue.
For local development you can still set `MULTICA_WORKSPACE_ID` directly to skip pairing, but that should be treated as a debug shortcut rather than the normal flow.
## Local Development Notes
- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.
- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container.
- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout.

View file

@ -1,12 +1,14 @@
name: super_multica
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: ${POSTGRES_DB:-multica}
POSTGRES_DB: multica
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data

View file

@ -1,10 +1,21 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault } from "./helpers";
import { createTestApi, loginAsDefault } from "./helpers";
import type { TestApiClient } from "./fixtures";
test.describe("Comments", () => {
test("can add a comment on an issue", async ({ page }) => {
await loginAsDefault(page);
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await api.createIssue("E2E Comment Test " + Date.now());
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("can add a comment on an issue", async ({ page }) => {
// Wait for issues to load and click first one
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
@ -31,8 +42,6 @@ test.describe("Comments", () => {
});
test("comment submit button is disabled when empty", async ({ page }) => {
await loginAsDefault(page);
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();

View file

@ -7,6 +7,12 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
interface TestWorkspace {
id: string;
name: string;
slug: string;
}
export class TestApiClient {
private token: string | null = null;
private workspaceId: string | null = null;
@ -23,7 +29,7 @@ export class TestApiClient {
return data;
}
async getWorkspaces() {
async getWorkspaces(): Promise<TestWorkspace[]> {
const res = await this.authedFetch("/api/workspaces");
return res.json();
}
@ -32,6 +38,34 @@ export class TestApiClient {
this.workspaceId = id;
}
async ensureWorkspace(name = "E2E Workspace", slug = "e2e-workspace") {
const workspaces = await this.getWorkspaces();
const workspace = workspaces.find((item) => item.slug === slug) ?? workspaces[0];
if (workspace) {
this.workspaceId = workspace.id;
return workspace;
}
const res = await this.authedFetch("/api/workspaces", {
method: "POST",
body: JSON.stringify({ name, slug }),
});
if (res.ok) {
const created = (await res.json()) as TestWorkspace;
this.workspaceId = created.id;
return created;
}
const refreshed = await this.getWorkspaces();
const created = refreshed.find((item) => item.slug === slug) ?? refreshed[0];
if (created) {
this.workspaceId = created.id;
return created;
}
throw new Error(`Failed to ensure workspace ${slug}: ${res.status} ${res.statusText}`);
}
async createIssue(title: string, opts?: Record<string, unknown>) {
const res = await this.authedFetch("/api/issues", {
method: "POST",

View file

@ -1,31 +1,33 @@
import { type Page } from "@playwright/test";
import { TestApiClient } from "./fixtures";
const DEFAULT_E2E_NAME = "E2E User";
const DEFAULT_E2E_EMAIL = "e2e@multica.ai";
const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
/**
* Login as the seeded user (has workspace and issues).
* Log in as the default E2E user and ensure the workspace exists first.
*/
export async function loginAsDefault(page: Page) {
const api = new TestApiClient();
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
await page.goto("/login");
await page.fill('input[placeholder="Name"]', "Jiayuan Zhang");
await page.fill('input[placeholder="Email"]', "jiayuan@multica.ai");
await page.fill('input[placeholder="Name"]', DEFAULT_E2E_NAME);
await page.fill('input[placeholder="Email"]', DEFAULT_E2E_EMAIL);
await page.click('button[type="submit"]');
await page.waitForURL("**/issues", { timeout: 10000 });
}
/**
* Open the workspace switcher dropdown menu.
*/
/**
* Create a TestApiClient logged in as the default seeded user.
* Create a TestApiClient logged in as the default E2E user.
* Call api.cleanup() in afterEach to remove test data created during the test.
*/
export async function createTestApi(): Promise<TestApiClient> {
const api = new TestApiClient();
await api.login("jiayuan@multica.ai", "Jiayuan Zhang");
const workspaces = await api.getWorkspaces();
if (workspaces.length > 0) {
api.setWorkspaceId(workspaces[0].id);
}
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
return api;
}

View file

@ -49,7 +49,7 @@ test.describe("Issues", () => {
});
test("can navigate to issue detail page", async ({ page }) => {
// Create a known issue via API so we don't depend on seed data
// Create a known issue via API so the test controls its own fixture
const issue = await api.createIssue("E2E Detail Test " + Date.now());
// Reload to see the new issue

View file

@ -6,14 +6,18 @@ set -euo pipefail
# Usage: bash scripts/check.sh
# ==========================================================================
ENV_FILE="${ENV_FILE:-$(if [ -f .env ]; then echo .env; elif [ -f .env.worktree ]; then echo .env.worktree; else echo .env; fi)}"
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
ENV_FILE="${ENV_FILE:-.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "Missing env file: $ENV_FILE"
echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."
exit 1
fi
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
@ -22,8 +26,6 @@ FRONTEND_PORT="${FRONTEND_PORT:-3000}"
PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}"
export PLAYWRIGHT_BASE_URL
COMPOSE_CMD=(docker compose --env-file "$ENV_FILE")
BACKEND_PID=""
FRONTEND_PID=""
STARTED_BACKEND=false
@ -77,16 +79,7 @@ wait_for_port() {
# --------------------------------------------------------------------------
echo "==> Using env file: $ENV_FILE"
echo "==> Checking PostgreSQL..."
if pg_isready -h localhost -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
echo " Already running."
else
echo " Starting via docker compose..."
"${COMPOSE_CMD[@]}" up -d
until "${COMPOSE_CMD[@]}" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
sleep 1
done
echo " PostgreSQL ready."
fi
bash scripts/ensure-postgres.sh "$ENV_FILE"
# --------------------------------------------------------------------------
# Step 1: TypeScript typecheck

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:-.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "Missing env file: $ENV_FILE"
echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."
exit 1
fi
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-multica}"
export PGPASSWORD="$POSTGRES_PASSWORD"
echo "==> Ensuring shared PostgreSQL container is running on localhost:5432..."
docker compose up -d postgres
echo "==> Waiting for PostgreSQL to be ready..."
until docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d postgres > /dev/null 2>&1; do
sleep 1
done
echo "==> Ensuring database '$POSTGRES_DB' exists..."
db_exists="$(docker compose exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'")"
if [ "$db_exists" != "1" ]; then
docker compose exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE \"$POSTGRES_DB\"" \
> /dev/null
fi
echo "✓ PostgreSQL ready. Application database: $POSTGRES_DB"

View file

@ -17,15 +17,13 @@ fi
hash_value="$(printf '%s' "$PWD" | cksum | awk '{print $1}')"
offset=$((hash_value % 1000))
postgres_db="multica_${slug}"
postgres_port=$((15432 + offset))
postgres_db="multica_${slug}_${offset}"
postgres_port=5432
backend_port=$((18080 + offset))
frontend_port=$((13000 + offset))
frontend_origin="http://localhost:${frontend_port}"
compose_project_name="multica_${slug}_${offset}"
cat > "$ENV_FILE" <<EOF
COMPOSE_PROJECT_NAME=${compose_project_name}
POSTGRES_DB=${postgres_db}
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
@ -47,10 +45,11 @@ NEXT_PUBLIC_WS_URL=ws://localhost:${backend_port}/ws
EOF
echo "Generated $ENV_FILE for worktree '$worktree_name'"
echo " Postgres: ${postgres_db} on localhost:${postgres_port}"
echo " Shared Postgres: localhost:${postgres_port}"
echo " Database: ${postgres_db}"
echo " Backend: http://localhost:${backend_port}"
echo " Frontend: ${frontend_origin}"
echo ""
echo "Next steps:"
echo " make setup"
echo " make start"
echo " make setup-worktree"
echo " make start-worktree"

View file

@ -1,221 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("Unable to connect to database: %v", err)
}
defer pool.Close()
// Create seed user
var userID string
err = pool.QueryRow(ctx, `
INSERT INTO "user" (name, email, avatar_url)
VALUES ('Jiayuan Zhang', 'jiayuan@multica.ai', NULL)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id
`).Scan(&userID)
if err != nil {
log.Fatalf("Failed to create user: %v", err)
}
fmt.Printf("User created: %s\n", userID)
// Create seed workspace
var workspaceID string
err = pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description)
VALUES ('Multica', 'multica', 'AI-native task management')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id
`).Scan(&workspaceID)
if err != nil {
log.Fatalf("Failed to create workspace: %v", err)
}
fmt.Printf("Workspace created: %s\n", workspaceID)
// Add user as owner
_, err = pool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT (workspace_id, user_id) DO NOTHING
`, workspaceID, userID)
if err != nil {
log.Fatalf("Failed to create member: %v", err)
}
fmt.Println("Member created")
// Create some agents
agents := []struct {
name string
description string
runtimeMode string
status string
skills string
tools string
triggers string
}{
{
"Deep Research Agent",
"Performs deep research on topics using web search and analysis",
"local", "idle",
"# Deep Research Agent\n\nYou are a research agent that performs thorough analysis on assigned topics.\n\n## Workflow\n1. Break down the research question into sub-questions\n2. Use web search to gather information from multiple sources\n3. Cross-reference and validate findings\n4. Synthesize a comprehensive report\n5. Post the report as a comment on the issue\n\n## Output Format\nAlways produce a structured report with:\n- Executive Summary\n- Key Findings\n- Sources\n- Recommendations",
`[{"id":"tool-1","name":"Google Search","description":"Search the web for information","auth_type":"api_key","connected":true,"config":{}},{"id":"tool-2","name":"Web Scraper","description":"Extract content from web pages","auth_type":"none","connected":true,"config":{}}]`,
`[{"id":"trigger-1","type":"on_assign","enabled":true,"config":{}}]`,
},
{
"Code Review Bot",
"Reviews pull requests and provides feedback on code quality",
"cloud", "idle",
"# Code Review Bot\n\nYou review code changes and provide constructive feedback.\n\n## Review Criteria\n- Code correctness and logic\n- Performance implications\n- Security vulnerabilities\n- Code style and readability\n- Test coverage\n\n## Process\n1. Read the issue description for context\n2. Analyze code changes\n3. Post review comments on specific lines\n4. Provide an overall summary",
`[{"id":"tool-3","name":"GitHub","description":"Access GitHub repositories and PRs","auth_type":"oauth","connected":true,"config":{}}]`,
`[{"id":"trigger-2","type":"on_assign","enabled":true,"config":{}}]`,
},
{
"Daily Standup Bot",
"Generates daily standup summaries from recent activity",
"cloud", "working",
"# Daily Standup Bot\n\nGenerate a daily standup summary based on workspace activity.\n\n## Tasks\n1. Collect all issue status changes from the last 24 hours\n2. Summarize what each team member worked on\n3. Identify blocked items\n4. Post the summary to the team channel",
`[{"id":"tool-4","name":"Slack","description":"Send messages to Slack channels","auth_type":"oauth","connected":true,"config":{"channel":"#standup"}}]`,
`[{"id":"trigger-3","type":"scheduled","enabled":true,"config":{"cron":"0 9 * * 1-5","timezone":"Asia/Shanghai"}}]`,
},
{
"Local Dev Agent",
"A local development agent running on your machine",
"local", "offline",
"",
`[]`,
`[{"id":"trigger-4","type":"on_assign","enabled":true,"config":{}}]`,
},
}
for _, a := range agents {
var agentID string
// Check if agent already exists
err = pool.QueryRow(ctx, `
SELECT id FROM agent WHERE workspace_id = $1 AND name = $2
`, workspaceID, a.name).Scan(&agentID)
if err == nil {
fmt.Printf("Agent exists: %s (%s)\n", a.name, agentID)
continue
}
err = pool.QueryRow(ctx, `
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at)
VALUES (
$1,
NULL,
$2,
$3,
$4,
$5,
$6,
'{"seed":true}'::jsonb,
CASE WHEN $5 = 'online' THEN now() ELSE NULL END
)
RETURNING id
`,
workspaceID,
a.name+" Runtime",
a.runtimeMode,
map[string]string{"cloud": "multica_agent", "local": "seed_local"}[a.runtimeMode],
map[bool]string{true: "offline", false: "online"}[a.status == "offline"],
map[string]string{"cloud": "Seeded cloud runtime", "local": "Seeded local runtime"}[a.runtimeMode],
).Scan(&agentID)
if err != nil {
log.Printf("Failed to create runtime for agent %s: %v", a.name, err)
continue
}
runtimeID := agentID
err = pool.QueryRow(ctx, `
INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_id, status, owner_id, skills, tools, triggers)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb)
RETURNING id
`, workspaceID, a.name, a.description, a.runtimeMode, runtimeID, a.status, userID, a.skills, a.tools, a.triggers).Scan(&agentID)
if err != nil {
log.Printf("Failed to create agent %s: %v", a.name, err)
continue
}
fmt.Printf("Agent created: %s (%s)\n", a.name, agentID)
}
// Create seed issues
issues := []struct {
title string
description string
status string
priority string
position float64
}{
{"Add multi-workspace support", "Users should be able to create and switch between multiple workspaces.", "backlog", "medium", 1},
{"Agent long-term memory persistence", "Agents need persistent memory across sessions for better context.", "backlog", "low", 2},
{"Design the agent config UI", "Create a configuration interface for agent settings and capabilities.", "todo", "high", 3},
{"Implement issue list API endpoint", "Build the REST API for listing, filtering, and paginating issues.", "in_progress", "urgent", 4},
{"Implement OAuth login flow", "Set up OAuth 2.0 with Google for user authentication.", "in_progress", "high", 5},
{"Add WebSocket reconnection logic", "Handle disconnections gracefully with exponential backoff.", "in_review", "medium", 6},
{"Set up CI/CD pipeline", "Configure GitHub Actions for automated testing and deployment.", "done", "high", 7},
{"Design database schema", "Create the initial PostgreSQL schema for all entities.", "done", "urgent", 8},
{"Implement real-time notifications", "Push notifications to users via WebSocket when issues change.", "todo", "medium", 9},
{"Agent task queue management", "Build the task dispatching and queue management system for agents.", "todo", "high", 10},
}
for _, iss := range issues {
var issueID string
// Check if issue already exists
err = pool.QueryRow(ctx, `
SELECT id FROM issue WHERE workspace_id = $1 AND title = $2
`, workspaceID, iss.title).Scan(&issueID)
if err == nil {
fmt.Printf("Issue exists: %s (%s)\n", iss.title, issueID)
continue
}
err = pool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, description, status, priority, creator_type, creator_id, position)
VALUES ($1, $2, $3, $4, $5, 'member', $6, $7)
RETURNING id
`, workspaceID, iss.title, iss.description, iss.status, iss.priority, userID, iss.position).Scan(&issueID)
if err != nil {
log.Printf("Failed to create issue %s: %v", iss.title, err)
continue
}
fmt.Printf("Issue created: %s (%s)\n", iss.title, issueID)
}
// Create seed comment (only if not already present)
var commentExists bool
_ = pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM comment c
JOIN issue i ON c.issue_id = i.id
WHERE i.workspace_id = $1 AND i.title = 'Implement issue list API endpoint'
AND c.content = 'This is a high priority item for Q2.'
)
`, workspaceID).Scan(&commentExists)
if !commentExists {
_, err = pool.Exec(ctx, `
INSERT INTO comment (issue_id, author_type, author_id, content, type)
SELECT i.id, 'member', $2, 'This is a high priority item for Q2.', 'comment'
FROM issue i WHERE i.workspace_id = $1 AND i.title = 'Implement issue list API endpoint'
`, workspaceID, userID)
if err != nil {
log.Printf("Failed to create comment: %v", err)
}
}
fmt.Println("\nSeed data created successfully!")
fmt.Printf("\nUser ID: %s\n", userID)
fmt.Printf("Workspace ID: %s\n", workspaceID)
}

View file

@ -18,7 +18,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/realtime"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
var (
@ -30,48 +29,48 @@ var (
var jwtSecret = []byte("multica-dev-secret-change-in-production")
const (
integrationTestEmail = "integration-test@multica.ai"
integrationTestName = "Integration Tester"
integrationTestWorkspaceSlug = "integration-tests"
)
func TestMain(m *testing.M) {
ctx := context.Background()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(context.Background(), dbURL)
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
fmt.Printf("Skipping integration tests: could not connect to database: %v\n", err)
os.Exit(0)
}
defer pool.Close()
// Get seed data IDs
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
row.Scan(&testUserID)
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
row.Scan(&testWorkspaceID)
if testUserID == "" || testWorkspaceID == "" {
fmt.Println("Skipping integration tests: seed data not found. Run 'go run ./cmd/seed/' first.")
os.Exit(0)
testUserID, testWorkspaceID, err = setupIntegrationTestFixture(ctx, pool)
if err != nil {
fmt.Printf("Failed to set up integration test fixture: %v\n", err)
pool.Close()
os.Exit(1)
}
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
_ = queries
router := NewRouter(pool, hub)
testServer = httptest.NewServer(router)
defer testServer.Close()
// Login to get a real JWT token
loginBody, _ := json.Marshal(map[string]string{
"email": "jiayuan@multica.ai",
"name": "Jiayuan Zhang",
"email": integrationTestEmail,
"name": integrationTestName,
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody))
if err != nil {
fmt.Printf("Skipping: login failed: %v\n", err)
testServer.Close()
pool.Close()
os.Exit(0)
}
defer resp.Body.Close()
@ -85,7 +84,81 @@ func TestMain(m *testing.M) {
json.NewDecoder(resp.Body).Decode(&loginResp)
testToken = loginResp.Token
os.Exit(m.Run())
code := m.Run()
if err := cleanupIntegrationTestFixture(context.Background(), pool); err != nil {
fmt.Printf("Failed to clean up integration test fixture: %v\n", err)
if code == 0 {
code = 1
}
}
testServer.Close()
pool.Close()
os.Exit(code)
}
func setupIntegrationTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, string, error) {
if err := cleanupIntegrationTestFixture(ctx, pool); err != nil {
return "", "", err
}
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ($1, $2)
RETURNING id
`, integrationTestName, integrationTestEmail).Scan(&userID); err != nil {
return "", "", err
}
var workspaceID string
if err := pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
RETURNING id
`, "Integration Tests", integrationTestWorkspaceSlug, "Temporary workspace for router integration tests").Scan(&workspaceID); err != nil {
return "", "", err
}
if _, err := pool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, workspaceID, userID); err != nil {
return "", "", err
}
var runtimeID string
if err := pool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
RETURNING id
`, workspaceID, "Integration Test Runtime", "integration_test_runtime", "Integration test runtime").Scan(&runtimeID); err != nil {
return "", "", err
}
if _, err := pool.Exec(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id, skills, tools, triggers
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '', '[]'::jsonb, '[]'::jsonb)
`, workspaceID, "Integration Test Agent", runtimeID, userID); err != nil {
return "", "", err
}
return userID, workspaceID, nil
}
func cleanupIntegrationTestFixture(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, integrationTestWorkspaceSlug); err != nil {
return err
}
if _, err := pool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, integrationTestEmail); err != nil {
return err
}
return nil
}
// Helper to make authenticated requests

View file

@ -12,55 +12,118 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/realtime"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
var testHandler *Handler
var testUserID string
var testWorkspaceID string
var testToken string
const (
handlerTestEmail = "handler-test@multica.ai"
handlerTestName = "Handler Test User"
handlerTestWorkspaceSlug = "handler-tests"
)
func TestMain(m *testing.M) {
ctx := context.Background()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(context.Background(), dbURL)
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
fmt.Printf("Skipping tests: could not connect to database: %v\n", err)
os.Exit(0)
}
defer pool.Close()
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
testHandler = New(queries, pool, hub)
// Get seed user and workspace IDs
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
row.Scan(&testUserID)
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
row.Scan(&testWorkspaceID)
if testUserID == "" || testWorkspaceID == "" {
fmt.Println("Skipping tests: seed data not found. Run 'go run ./cmd/seed/' first.")
os.Exit(0)
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
if err != nil {
fmt.Printf("Failed to set up handler test fixture: %v\n", err)
pool.Close()
os.Exit(1)
}
// Generate a test token
import_jwt(testUserID)
os.Exit(m.Run())
code := m.Run()
if err := cleanupHandlerTestFixture(context.Background(), pool); err != nil {
fmt.Printf("Failed to clean up handler test fixture: %v\n", err)
if code == 0 {
code = 1
}
}
pool.Close()
os.Exit(code)
}
func import_jwt(userID string) {
// Simple token generation for tests using the login handler
// We'll just set the headers directly instead
testToken = userID // We'll use X-User-ID header directly
func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, string, error) {
if err := cleanupHandlerTestFixture(ctx, pool); err != nil {
return "", "", err
}
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ($1, $2)
RETURNING id
`, handlerTestName, handlerTestEmail).Scan(&userID); err != nil {
return "", "", err
}
var workspaceID string
if err := pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
RETURNING id
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests").Scan(&workspaceID); err != nil {
return "", "", err
}
if _, err := pool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, workspaceID, userID); err != nil {
return "", "", err
}
var runtimeID string
if err := pool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
RETURNING id
`, workspaceID, "Handler Test Runtime", "handler_test_runtime", "Handler test runtime").Scan(&runtimeID); err != nil {
return "", "", err
}
if _, err := pool.Exec(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id, skills, tools, triggers
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4, '', '[]'::jsonb, '[]'::jsonb)
`, workspaceID, "Handler Test Agent", runtimeID, userID); err != nil {
return "", "", err
}
return userID, workspaceID, nil
}
func cleanupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, handlerTestWorkspaceSlug); err != nil {
return err
}
if _, err := pool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, handlerTestEmail); err != nil {
return err
}
return nil
}
func newRequest(method, path string, body any) *http.Request {