Come è fatta ELVETH Tasks
Dashboard di automazione che trasforma il flusso "una richiesta a Claude Code alla volta" in una coda asincrona di task eseguiti su VPS, con PR su GitHub pronte da revisionare. Qui sotto trovi l'infrastruttura in dettaglio: Cloudflare Worker unificato, schema D1, VPS Hostinger, runner Node + Claude Code Max.
Architettura ad alto livello
Un solo Worker fa tre cose contemporaneamente: serve la dashboard statica, espone l'API privata per il pannello, espone l'API pubblica /api/queue/* per il VPS. Cloudflare Access protegge tutto tranne la queue, che usa Bearer token.
- tasks.elveth.com/
- tasks.elveth.com/projects
- tasks.elveth.com/tasks
- tasks.elveth.com/settings
- /api/projects · CRUD
- /api/tasks · CRUD
- /api/stats · counters
- /api/queue/* · Bearer
- /webhooks/github
- projects
- tasks
- workers_heartbeat
- output log
- diff archive
- artefatti
- claim → run → push
- eseguito in cwd repo
- push + apri PR
Dashboard Next.js (output: 'export') e API Hono nello stesso Worker. Same-origin: niente CORS, un solo cookie di sessione.
L'UI è dietro Cloudflare Access (solo email Elveth). /api/queue/* è in path exclusion e si autentica via VPS_API_TOKEN.
Il VPS sta dietro NAT: nessuna porta aperta. Chiede al Worker `GET /api/queue/next` ogni 10s ed esegue se trova lavoro.
Lo strato Cloudflare
Tutto quello che gira in cloud sta su Cloudflare: un solo Worker con Static Assets + Hono, D1 come database, R2 per i log, Access come gateway di autenticazione.
- Nome progetto
- elveth-tasks
- Dominio
- tasks.elveth.com
- Entry
- apps/worker/src/index.ts
- Compatibility flags
- nodejs_compat
- Static assets
- ./apps/panel/out (Next export)
- Deploy
- pnpm deploy (build:panel + wrangler deploy)
- Binding
- DB
- Database name
- elveth-tasks-db
- ORM
- Drizzle
- Migrations
- ./packages/db/migrations
- Migration corrente
- 0001_init.sql
- Tabelle
- projects · tasks · workers_heartbeat
- Binding
- LOGS
- Bucket
- elveth-tasks-logs
- Uso
- Log completo output Claude + artefatti per task
- Upload
- Presigned URL via /api/queue/log-upload-url
- Chiave
- tasks/<task_id>/output.log
- Provider
- Cloudflare Zero Trust
- Politica
- Email @elveth.com
- Sessione
- Cookie (same-origin con API)
- Path exclusion
- /api/queue/*
- Auth queue
- Bearer VPS_API_TOKEN (32 byte hex)
Il VPS chiama /api/queue/* senza login utente. Quei path devono essere esclusi da Access (altrimenti riceverebbero il redirect alla pagina di login). La verifica si fa con curl -i https://tasks.elveth.com/api/queue/next?worker=test: deve rispondere 401 con messaggio del Worker, non un redirect HTML.
- GET/api/projects
- POST/api/projects
- GET/api/projects/:id
- PATCH/api/projects/:id
- DELETE/api/projects/:id
- GET/api/tasks
- POST/api/tasks
- GET/api/tasks/:id
- PATCH/api/tasks/:id
- DELETE/api/tasks/:id
- GET/api/stats
- GET/api/stats/:project_id
- GET/api/queue/next?worker=<id>
- GET/api/queue/projects/:id
- PATCH/api/queue/tasks/:id
- POST/api/queue/heartbeat
- POST/api/queue/log-upload-url
- POST/api/webhooks/github
VPS Hostinger — il braccio esecutivo
L'unico componente che chiama gli altri: sta dietro NAT, niente porte aperte. Pollinga il Worker e quando trova un task lo esegue con Claude Code Max, poi apre PR su GitHub.
- Provider
- Hostinger
- Piano
- KVM 1
- CPU
- 1 vCPU
- RAM
- 4 GB
- Storage
- 50 GB SSD
- Location
- Frankfurt (DE)
- Costo
- ~5€/mese
- OS
- Ubuntu 24.04 LTS
- Runtime
- Node.js 20 LTS
- Process manager
- PM2 (restart automatico)
- Claude CLI
- @anthropic-ai/claude-code (piano Max)
- Git
- CLI standard
- GitHub CLI
- gh (token PAT scope repo)
- Sync logs
- rclone
- Worker per VPS
- 1 (parallelismo arriva in v2)
Login una sola volta col piano Max via SSH tunnel; sessione persistente sul disco.
Token salvato in ambiente del daemon; permette push e gh pr create sull'org elveth.
Variabile VPS_API_TOKEN identica al secret sul Worker (openssl rand -hex 32).
Il runner & il loop di Claude
Il daemon Node sul VPS è uno script semplice che fa una sola cosa: ciclicamente chiede un task, lo esegue, riporta l'esito. Repo separato (elveth/tasks-runner).
- 1claim_next
GET /api/queue/next?worker=<id> — atomic UPDATE che marca un pending come running e lo restituisce. Niente race condition.
- 2sync repo
cd su working_dir del progetto, git fetch + checkout main + pull, poi nuovo branch claude/task-<id>.
- 3claude -p
Esegue Claude Code Max passando base_prompt del progetto concatenato al prompt del task. Stesso ambiente di Mattia in locale.
- 4commit + push
Stage delle modifiche, commit con messaggio generato dal log Claude, git push -u origin del branch.
- 5gh pr create
Apre PR con titolo dal task e body con sintesi dell’output. Mai automerge.
- 6PATCH tasks/:id
Riporta esito (done / failed / needs_review), pr_url, output_summary e la chiave R2 del log completo.
- Trigger
- Output Claude contiene [NEEDS_REVIEW: ...]
- Stato task
- needs_review (auto-resume su pending all’answer)
- Campo question
- Estratto dal marker
- Risposta utente
- PATCH /api/tasks/:id { answer }
- Effetto
- Status torna pending, il task rientra in coda
- Naming branch
- claude/task-<id>
- Base branch
- main del progetto
- Push
- git push -u origin <branch>
- PR
- gh pr create (mai automerge)
- Review
- Mattia approva e mergea via GitHub web/mobile
Ogni 30 secondi il worker chiama POST /api/queue/heartbeat con il proprio ID, lo stato corrente (idle / working / error) e il task in lavorazione. La tabella workers_heartbeat permette di sapere dalla dashboard se il VPS è vivo e cosa sta facendo.
Schema D1 — tre tabelle, niente di più
Lo schema vive in packages/db (Drizzle ORM + SQL backup) e gira su Cloudflare D1. Single source of truth: packages/shared/src/entities.ts.
Contenitore di task. Mappato 1:1 a un repo GitHub e a una working directory sul VPS.
| Campo | Tipo | Note |
|---|---|---|
| id | TEXT PK | proj_<random> |
| name | TEXT | Nome leggibile, mostrato in UI |
| slug | TEXT UNIQUE | kebab-case, usato in /projects/[slug] |
| description | TEXT | Sintesi breve |
| github_repo | TEXT | Formato org/name (es. elveth/tasks) |
| default_branch | TEXT | Default "main" |
| base_prompt | TEXT | Contesto fisso prepended a ogni task |
| project_type | TEXT | shopify · cloudflare · generic |
| working_dir | TEXT | Path su VPS dove vive il clone |
| active | INTEGER | 1 attivo · 0 archiviato (soft delete) |
| color | TEXT | Hex per la UI |
| created_at | INTEGER | unix seconds |
| updated_at | INTEGER | unix seconds |
Unità di lavoro per Claude. Un task = un branch = una PR. Si muove tra gli stati di TaskStatus.
| Campo | Tipo | Note |
|---|---|---|
| id | TEXT PK | task_<random> |
| project_id | TEXT FK | → projects(id) ON DELETE CASCADE |
| title | TEXT | ≤100 char, mostrato in lista |
| prompt | TEXT | Descrizione dettagliata per Claude |
| status | TEXT | pending · running · done · failed · needs_review · merged |
| priority | INTEGER | -1 bassa · 0 normale · 1 alta |
| branch | TEXT | claude/task-<id> (popolato dal worker) |
| pr_url | TEXT | URL della PR su GitHub |
| pr_number | INTEGER | Numero PR |
| preview_url | TEXT | URL preview (Cloudflare / Shopify) |
| output_summary | TEXT | Sintesi mostrata in UI |
| output_log_key | TEXT | Chiave R2 del log completo |
| question | TEXT | Se status=needs_review: la domanda di Claude |
| answer | TEXT | Risposta utente; trigger auto-resume a pending |
| worker_id | TEXT | Quale worker VPS ha preso il task |
| cost_usd | REAL | Costo (per futuri piani a consumo) |
| duration_seconds | INTEGER | Durata esecuzione |
| error_message | TEXT | Compilato se status=failed |
| created_at | INTEGER | unix seconds |
| started_at | INTEGER | unix seconds |
| completed_at | INTEGER | unix seconds |
Stato live dei worker VPS. In v1 ce n'è uno solo; il parallelismo arriva in v2.
| Campo | Tipo | Note |
|---|---|---|
| worker_id | TEXT PK | es. vps-1-w1 |
| last_seen | INTEGER | unix seconds — aggiornato ogni 30s |
| current_task_id | TEXT FK | → tasks(id) ON DELETE SET NULL |
| status | TEXT | idle · working · error |
| metadata | TEXT | JSON: hostname, versione runner, ecc. |
GitHub e flusso PR
Il codice vive su github.com/elveth. Il runner apre PR; Mattia approva sempre manualmente. main è protetto: niente push diretti, niente automerge.
- Branch protetto
- main (default branch)
- Block force push
- on
- Restrict deletions
- on
- Require PR before merging
- on (0 approvals richieste)
- Naming branch automatici
- claude/task-<id>
- Webhook GitHub
- POST /api/webhooks/github
- Eventi ascoltati
- pull_request (opened, closed, merged)
- Sincronizzazione
- Aggiorna status del task in DB
- Deploy progetto
- Su merge → CI del progetto stesso
- ELVETH Tasks
- Cloudflare Workers Build su push a main
Stack tecnico
Niente magie: tech che usiamo già altrove (Cloudflare, GitHub, Hostinger), strumenti veloci e poche dipendenze.
| Layer | Tecnologia | Note |
|---|---|---|
| Frontend | Next.js 15 · React 19 · TypeScript | output: 'export' → assets statici servits dal Worker |
| UI | Tailwind CSS v4 · shadcn/ui | Dark mode first-class, niente glassmorphism |
| Backend | Cloudflare Workers · Hono | Routing type-safe, defineTool() per ogni operazione |
| Database | Cloudflare D1 · Drizzle ORM | SQLite distribuito, migrazioni in packages/db |
| Storage log | Cloudflare R2 | Output completo Claude + diff archive per task |
| Auth UI | Cloudflare Access | Politica email @elveth.com, cookie same-origin |
| Auth queue | Bearer VPS_API_TOKEN | Secret del Worker, 32 byte hex |
| Validazione | Zod | Input REST, queue e webhooks; uscita tipizzata |
| Pkg manager | pnpm workspaces | Monorepo: apps/* + packages/* |
| Lint/format | Biome | pnpm check deve passare prima del commit |
| CI/CD | Cloudflare Workers Build | Auto-deploy su push a main, build + worker in un colpo |
| VPS | Hostinger KVM 1 · Ubuntu 24.04 · PM2 | Node 20 + Claude Code Max + gh CLI |
| MCP (futuro) | @modelcontextprotocol/sdk | Stesso defineTool() esposto ad agenti AI |
Costi attesi
Quasi tutto è già pagato in altri abbonamenti: l'unico costo aggiuntivo dedicato a ELVETH Tasks è il VPS Hostinger.
Workers, D1, R2, Access — tutto sotto i limiti gratuiti.
1 vCPU, 4 GB RAM, 50 GB SSD, location Frankfurt.
Login OAuth sul VPS riusa lo stesso piano Max.
Org elveth su piano esistente, dominio elveth.com già attivo.