refactor(dev): share postgres across main and worktrees
This commit is contained in:
parent
94c9b07bfb
commit
2c28c4cba2
16 changed files with 839 additions and 359 deletions
|
|
@ -1,5 +1,4 @@
|
|||
# Database
|
||||
COMPOSE_PROJECT_NAME=super_multica
|
||||
POSTGRES_DB=multica
|
||||
POSTGRES_USER=multica
|
||||
POSTGRES_PASSWORD=multica
|
||||
|
|
|
|||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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
471
LOCAL_DEVELOPMENT.md
Normal 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
|
||||
```
|
||||
61
Makefile
61
Makefile
|
|
@ -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
|
||||
|
|
|
|||
40
README.md
40
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
scripts/ensure-postgres.sh
Normal file
42
scripts/ensure-postgres.sh
Normal 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue