Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ test/e2e/.auth/

# Docker
docker/data/

# Codegraph local index
.codegraph/
89 changes: 89 additions & 0 deletions MANUAL_INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,95 @@ This guide covers installing Taskwondo without Docker Compose. You'll need to pr
- S3-compatible object storage (MinIO, AWS S3, DigitalOcean Spaces, etc.)
- Nginx (or any web server that can serve static files and reverse-proxy)

## Option A: All-in-One Docker Image

The simplest way to deploy Taskwondo. A single container bundles the API, worker, and web frontend behind an Nginx reverse proxy — only **one port** needs to be exposed.

### Prerequisites

- Docker
- PostgreSQL 15+
- NATS with JetStream enabled
- S3-compatible object storage

### Run

```bash
docker run -d \
--name taskwondo \
-p 80:80 \
-e DATABASE_URL="postgres://user:pass@host:5432/taskwondo?sslmode=disable" \
-e JWT_SECRET="your-jwt-secret" \
-e STORAGE_ENDPOINT="s3.amazonaws.com" \
-e STORAGE_ACCESS_KEY="your-access-key" \
-e STORAGE_SECRET_KEY="your-secret-key" \
-e STORAGE_BUCKET="taskwondo-attachments" \
-e NATS_URL="nats://host:4222" \
ghcr.io/marcoshack/taskwondo/all-in-one:latest
```

Then open [http://localhost](http://localhost). On first start the API runs migrations and seeds the admin user — check the container logs for credentials:

```bash
docker logs taskwondo
```

### Environment Variables

All variables from the standard `.env.template` are supported. Key ones:

| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `JWT_SECRET` | Secret for signing JWT tokens (min 32 chars) |
| `STORAGE_ENDPOINT` | S3-compatible storage endpoint |
| `STORAGE_ACCESS_KEY` | Storage access key |
| `STORAGE_SECRET_KEY` | Storage secret key |
| `STORAGE_BUCKET` | Storage bucket name |
| `NATS_URL` | NATS server URL |
| `API_PORT` | Internal API port (default `8080`, no need to change) |
| `ADMIN_EMAIL` | Admin user email (optional) |
| `ADMIN_PASSWORD` | Admin user password (optional, auto-generated if omitted) |

### Build from Source

```bash
git clone https://github.com/marcoshack/taskwondo.git
cd taskwondo
docker build -f docker/Dockerfile.all-in-one -t taskwondo:local .
docker run -d --name taskwondo -p 80:80 \
-e DATABASE_URL="postgres://user:pass@host:5432/taskwondo?sslmode=disable" \
-e JWT_SECRET="your-jwt-secret" \
-e STORAGE_ENDPOINT="s3.amazonaws.com" \
-e STORAGE_ACCESS_KEY="your-access-key" \
-e STORAGE_SECRET_KEY="your-secret-key" \
-e STORAGE_BUCKET="taskwondo-attachments" \
-e NATS_URL="nats://host:4222" \
taskwondo:local
```

### Architecture

The all-in-one image uses **supervisord** to manage three processes inside a single container:

```
┌─────────────────────────────────┐
│ Nginx (:80) — single entry │
│ ├── static files (frontend) │
│ ├── /api/* → API (:8080) │
│ └── /healthz, /readyz, /metrics│
├─────────────────────────────────┤
│ API server (:8080, internal) │
│ Worker (background jobs) │
└─────────────────────────────────┘
```

---

## Option B: Manual Installation (Binaries)

If you prefer to run Taskwondo without Docker, follow the steps below.

## Download

Download the server bundle (`taskwondo-server-*.tar.gz`) from the [Releases](https://github.com/marcoshack/taskwondo/releases) page. The [Dev Build](https://github.com/marcoshack/taskwondo/releases/tag/dev) release always has the latest build from `main`.
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ See [more screenshots](docs/overview.md) for a full walkthrough of features.

## Quick Start

### Option 1: Docker Compose (Multi-Container)

```bash
git clone https://github.com/marcoshack/taskwondo.git
cd taskwondo
Expand All @@ -115,6 +117,30 @@ cd taskwondo

Then open [http://localhost:3000](http://localhost:3000) and log in with the admin credentials printed by the installer.

### Option 2: All-in-One Docker Image (Single Container)

For simpler deployments, use the all-in-one image that bundles API, worker, and frontend into a single container with only one port exposed:

```bash
docker run -d \
--name taskwondo \
-p 80:80 \
-e DATABASE_URL="[REDACTED:connection_string]host:5432/taskwondo?sslmode=disable" \
-e JWT_SECRET="your-jwt-secret" \
-e STORAGE_ENDPOINT="s3.amazonaws.com" \
-e STORAGE_ACCESS_KEY="your-access-key" \
-e STORAGE_SECRET_KEY="your-secret-key" \
-e STORAGE_BUCKET="taskwondo-attachments" \
-e NATS_URL="nats://host:4222" \
ghcr.io/marcoshack/taskwondo/all-in-one:latest
```

Then open [http://localhost](http://localhost). Check logs for admin credentials: `docker logs taskwondo`

For detailed configuration and build-from-source instructions, see [MANUAL_INSTALL.md](MANUAL_INSTALL.md).

### Auto-start on Boot

To start Taskwondo automatically on boot, install the included systemd service:

```bash
Expand Down
2 changes: 1 addition & 1 deletion api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func main() {

// Seed default namespace and backfill existing projects
if err := namespaceService.SeedDefaultNamespace(ctx); err != nil {
log.Fatal().Err(err).Msg("failed to seed default namespace")
log.Warn().Err(err).Msg("failed to seed default namespace (will retry on next startup)")
}

// Seed default limit settings (max projects/namespaces per user)
Expand Down
11 changes: 3 additions & 8 deletions api/cmd/worker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,6 @@ func main() {
defer db.Close()
log.Info().Int("max_open", cfg.WorkerDBPool).Msg("connected to database")

// Run migrations (idempotent)
if err := database.Migrate(ctx, db); err != nil {
log.Fatal().Err(err).Msg("failed to run migrations")
}

// Initialize repositories
statsRepo := repository.NewStatsRepository(db)
userRepo := repository.NewUserRepository(db)
Expand Down Expand Up @@ -183,18 +178,18 @@ func main() {

// Register on-call rotation notification task
notifyOncallRotation := workers.NewNotificationOncallRotationTask(
userRepo, emailSender, urlBuilder, log.Logger,
userRepo, userSettingRepo, emailSender, urlBuilder, log.Logger,
)
dispatcher.Register(notifyOncallRotation)

// Register on-call override notification tasks
notifyOncallOverrideCreated := workers.NewNotificationOncallOverrideCreatedTask(
userRepo, emailSender, urlBuilder, log.Logger,
userRepo, userSettingRepo, emailSender, urlBuilder, log.Logger,
)
dispatcher.Register(notifyOncallOverrideCreated)

notifyOncallOverrideCancelled := workers.NewNotificationOncallOverrideCancelledTask(
userRepo, emailSender, urlBuilder, log.Logger,
userRepo, userSettingRepo, emailSender, urlBuilder, log.Logger,
)
dispatcher.Register(notifyOncallOverrideCancelled)

Expand Down
3 changes: 3 additions & 0 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/marcoshack/taskwondo
go 1.25.5

require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
Expand All @@ -16,6 +17,7 @@ require (
github.com/rs/zerolog v1.34.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/oauth2 v0.36.0
golang.org/x/time v0.14.0
)

Expand All @@ -25,6 +27,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
Expand Down
6 changes: 6 additions & 0 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand All @@ -34,6 +36,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
Expand Down Expand Up @@ -147,6 +151,8 @@ golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
58 changes: 58 additions & 0 deletions api/internal/crypto/crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"

Expand Down Expand Up @@ -87,3 +88,60 @@ func (e *Encryptor) Decrypt(encoded string) (string, error) {

return string(plaintext), nil
}

// SealJSON marshals v, encrypts it with AES-256-GCM and returns it as an
// unpadded URL-safe base64 token, suitable for a query parameter such as an
// OAuth state. Confidentiality comes from the GCM tag as well as the cipher:
// a token that was not produced by this key cannot be forged.
func (e *Encryptor) SealJSON(v any) (string, error) {
plaintext, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("marshaling payload: %w", err)
}

block, err := aes.NewCipher(e.key)
if err != nil {
return "", fmt.Errorf("creating cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("creating gcm: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("generating nonce: %w", err)
}

sealed := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.RawURLEncoding.EncodeToString(sealed), nil
}

// OpenJSON decrypts a token produced by SealJSON into v. It fails if the token
// was sealed with a different key, tampered with, or malformed.
func (e *Encryptor) OpenJSON(token string, v any) error {
data, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return fmt.Errorf("decoding token: %w", err)
}
block, err := aes.NewCipher(e.key)
if err != nil {
return fmt.Errorf("creating cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("creating gcm: %w", err)
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return fmt.Errorf("token too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return fmt.Errorf("decrypting token: %w", err)
}
if err := json.Unmarshal(plaintext, v); err != nil {
return fmt.Errorf("unmarshaling payload: %w", err)
}
return nil
}
27 changes: 26 additions & 1 deletion api/internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,36 @@ func Migrate(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("creating migrator: %w", err)
}

// Check for dirty state and attempt recovery
version, dirty, _ := m.Version()
if dirty {
log.Ctx(ctx).Warn().Uint("version", version).Msg("database is in dirty state, attempting recovery")
// Rollback the dirty migration
if err := m.Steps(-1); err != nil {
// If rollback fails, force to previous clean version
if version > 0 {
if err := m.Force(int(version) - 1); err != nil {
return fmt.Errorf("forcing clean version: %w", err)
}
} else {
// If version is 0, force to 0
if err := m.Force(0); err != nil {
return fmt.Errorf("forcing version 0: %w", err)
}
}
}
// Recreate migrator after force
m, err = migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return fmt.Errorf("recreating migrator: %w", err)
}
}

if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("running migrations: %w", err)
}

version, dirty, _ := m.Version()
version, dirty, _ = m.Version()
log.Ctx(ctx).Info().Uint("version", version).Bool("dirty", dirty).Msg("database migrations applied")

return nil
Expand Down
37 changes: 20 additions & 17 deletions api/internal/database/migrations/000033_create_embeddings.up.sql
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(768) NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (entity_type, entity_id)
);

CREATE INDEX idx_embeddings_vector ON embeddings
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128);
CREATE INDEX idx_embeddings_project ON embeddings(project_id, entity_type) WHERE project_id IS NOT NULL;
CREATE INDEX idx_embeddings_entity ON embeddings(entity_type, entity_id);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'vector') THEN
EXECUTE 'CREATE EXTENSION IF NOT EXISTS vector';
EXECUTE 'CREATE TABLE embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(768) NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (entity_type, entity_id)
)';
EXECUTE 'CREATE INDEX idx_embeddings_vector ON embeddings USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128)';
EXECUTE 'CREATE INDEX idx_embeddings_project ON embeddings(project_id, entity_type) WHERE project_id IS NOT NULL';
EXECUTE 'CREATE INDEX idx_embeddings_entity ON embeddings(entity_type, entity_id)';
END IF;
END
$$;
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
ALTER TABLE embeddings ADD COLUMN IF NOT EXISTS path TEXT NOT NULL DEFAULT '';
DO $$
BEGIN
IF to_regclass('embeddings') IS NOT NULL THEN
ALTER TABLE embeddings ADD COLUMN IF NOT EXISTS path TEXT NOT NULL DEFAULT '';
END IF;
END
$$;
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
ALTER TABLE embeddings DROP COLUMN IF EXISTS path;
DO $$
BEGIN
IF to_regclass('embeddings') IS NOT NULL THEN
ALTER TABLE embeddings DROP COLUMN IF EXISTS path;
END IF;
END
$$;
Loading