Skip to content

Commit 2a6ea5b

Browse files
thatsmeclaude
andcommitted
BEAM clustering: multi-node workflow distribution (v0.3.8)
Multi-node support via Erlang distribution. Each node runs its own sequential executor independently; nodes exchange workflow outputs via send_to_workflow and receive_from_workflow core skills. New features: - ClusterManager GenServer: auto-registration, node monitoring (nodeup/nodedown), remote workflow triggers via :rpc.call - send_to_workflow skill: sends data to a workflow on another node (5s default timeout, configurable per-step) - receive_from_workflow skill: gate that must be step 1 to accept remote triggers, optional allowed_nodes ACL - Cluster admin UI page with node status and ping - Workflow "Run on" dropdown for node assignment (cluster-wide or pinned to specific node) - Cross-node config sync via PubSub over BEAM distribution - Gateway node assignment: telegram.node/discord.node in Config with auto-assign on enable. Single-node ignores assignments. - Global alert bar for unassigned gateways in cluster mode - docker-compose_swarm.yml for multi-node local testing - EPMD bundled in runtime image for long-name distribution - Node name shown in nav bar across all pages - Workflow cluster role badges (recv/send/bridge) Infrastructure: - 3 migrations: cluster_nodes table, node column on workflow_runs and workflows - Default node name alexclaw@node1.local (long names everywhere) - telegram.enabled config for explicit gateway control - Config.Loader subscribes to PubSub for cross-node ETS sync - Fixed 20 pre-existing ShellTest failures (missing shell.enabled) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5b2cd92 commit 2a6ea5b

47 files changed

Lines changed: 1461 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ alex_claw-*.tar
3232
/docs/*
3333
!/docs/screenshot/
3434

35-
# Personal docker-compose variants
35+
# Personal docker-compose variants (except swarm which is part of clustering)
3636
docker-compose_*.yml
37+
!docker-compose_swarm.yml
3738

3839
# Claude Code
3940
.claude/

ALEXCLAW_ARCHITECTURE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ AlexClaw.Application (one_for_one)
3131
├── Registry (CircuitBreakerRegistry) # Process registry for per-skill circuit breakers
3232
├── AlexClaw.Skills.CircuitBreakerSupervisor # DynamicSupervisor — per-skill circuit breakers
3333
├── AlexClaw.SkillSupervisor # DynamicSupervisor — spawns skill worker processes
34+
├── AlexClaw.Cluster.Manager # Node registration, discovery, remote workflow triggers
3435
├── AlexClaw.Scheduler # Quantum cron scheduler
3536
├── AlexClaw.Workflows.SchedulerSync # Syncs DB workflow schedules into Quantum jobs
3637
├── AlexClaw.Gateway.Telegram # Telegram long-polling bot
@@ -283,6 +284,29 @@ The circuit breaker wraps skill execution transparently in `Executor.execute_ste
283284

284285
When a workflow contains a `telegram_notify` step, the executor sends a start notification when the workflow begins and a failure notification if any step fails before reaching the notify step.
285286

287+
### Multi-Node Distribution — `AlexClaw.Cluster.Manager`
288+
289+
AlexClaw supports multi-node BEAM clustering. Each node runs its own sequential executor — no parallel step changes. Nodes exchange workflow outputs over Erlang distribution.
290+
291+
**ClusterManager** is a GenServer that:
292+
- Auto-registers itself and connecting nodes via `:net_kernel.monitor_nodes/1`
293+
- Pings known nodes from the DB 5 seconds after boot
294+
- Updates node status on `:nodeup`/`:nodedown` events
295+
- Handles incoming remote workflow triggers via `receive_workflow_data/3` (called by `:rpc.call` from remote nodes)
296+
- Validates the target workflow has `receive_from_workflow` as step 1 before allowing execution
297+
298+
**Cross-node workflow flow:**
299+
1. Node A runs a workflow with a `send_to_workflow` step
300+
2. `send_to_workflow` calls `:rpc.call(node_b, ClusterManager, :receive_workflow_data, ...)` with a 5s default timeout
301+
3. Node B's ClusterManager validates the gate, spawns the target workflow via `Task.Supervisor`
302+
4. The target workflow's `receive_from_workflow` step receives the data as input with `_source_node` in config
303+
304+
**Node assignment:** Workflows have an optional `node` field. If set, only that node's scheduler picks it up. If null, it's cluster-wide (any node can run it). The admin UI shows a "Run on" dropdown populated from connected cluster nodes.
305+
306+
**Gateway behavior in cluster:** Single-node mode ignores `telegram.node` and `discord.node` — gateways always start. In a cluster (connected peers detected), gateways only start on the assigned node. Setting `telegram.enabled = true` from a node's Config UI auto-assigns `telegram.node` to that node. Cross-node config changes propagate via PubSub over BEAM distribution.
307+
308+
**Configuration:** `NODE_NAME` (default: `alexclaw@node1.local`) and `CLUSTER_COOKIE` env vars. Long names (containing `.`) use EPMD for distribution. The naming convention is `alexclaw@nodeN.local` — same format for single-node and swarm.
309+
286310
---
287311

288312
## Skills
@@ -306,6 +330,8 @@ Skills are Elixir modules implementing the `AlexClaw.Skill` behaviour (`run/1`,
306330
| `shell` | `Shell` || Execute whitelisted OS commands for container introspection |
307331
| `coder` | `Coder` | local | Autonomous skill generation from natural language goals |
308332
| `web_automation` | `WebAutomation` || Browser recording and headless replay via sidecar |
333+
| `send_to_workflow` | `SendToWorkflow` || Send data to a workflow on another BEAM node via RPC |
334+
| `receive_from_workflow` | `ReceiveFromWorkflow` || Gate: accepts remote triggers when placed as step 1 |
309335

310336
### Skill API — `AlexClaw.Skills.SkillAPI`
311337

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ RUN apk add --no-cache libstdc++ openssl ncurses-libs postgresql-client git
5151
WORKDIR /app
5252

5353
COPY --from=build /app/_build/prod/rel/alex_claw ./
54+
55+
# EPMD is needed for BEAM long-name distribution (clustering)
56+
COPY --from=build /usr/local/lib/erlang/erts-*/bin/epmd /usr/local/bin/epmd
5457
COPY entrypoint.sh ./
5558
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
5659

INSTALLATION.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ ANTHROPIC_API_KEY=
5151
# GOOGLE_OAUTH_CLIENT_SECRET=
5252
# GOOGLE_OAUTH_REFRESH_TOKEN=
5353

54+
# === Clustering (optional — multi-node) ===
55+
# NODE_NAME=alexclaw@node1.local
56+
# CLUSTER_COOKIE=generate_a_random_secret
57+
5458
# === Advanced ===
5559
# ADMIN_PORT=5001
5660
```
@@ -494,6 +498,68 @@ docker compose exec alexclaw bin/alex_claw rpc \
494498

495499
---
496500

501+
## Clustering (Optional — Multi-Node)
502+
503+
AlexClaw supports running multiple instances connected via BEAM distribution. Each node runs independently with its own gateway connections and scheduler, sharing a single PostgreSQL database.
504+
505+
There are two Docker Compose files:
506+
507+
| File | Purpose |
508+
|---|---|
509+
| `docker-compose.yml` | **Single node** (default). One AlexClaw instance + DB + web-automator |
510+
| `docker-compose_swarm.yml` | **Multi-node**. Two AlexClaw nodes + shared DB. Each node has its own port and node name |
511+
512+
### Single Node (default)
513+
514+
No extra config needed. Just `docker compose up -d` as described in Setup.
515+
516+
The default node name is `alexclaw@node1.local`. Override via `NODE_NAME` in `.env` if needed. Single-node mode ignores gateway node assignments — Telegram and Discord always start.
517+
518+
### Multi-Node (same machine)
519+
520+
```bash
521+
docker compose -f docker-compose_swarm.yml up --build -d
522+
```
523+
524+
This starts two nodes:
525+
- **node1**`alexclaw@node1.local` on `localhost:5001`
526+
- **node2**`alexclaw@node2.local` on `localhost:5002`
527+
528+
Both share the same database and auto-discover each other on boot. The Cluster admin page (Admin > Cluster) shows connected nodes.
529+
530+
To add more nodes, duplicate a node block in `docker-compose_swarm.yml` with a new hostname and port.
531+
532+
### Cross-Node Workflows
533+
534+
1. Create a workflow with `receive_from_workflow` as step 1 — this is the receiver
535+
2. Create a workflow with `send_to_workflow` as a step — configure `target_node` and `target_workflow`
536+
3. Run the sender workflow — data flows from one node to the other over BEAM distribution
537+
538+
### Node Assignment
539+
540+
Both workflows and gateways support node assignment — cluster-wide or pinned to a specific node. The database is always the source of truth.
541+
542+
**Workflows:** each workflow has a "Run on" dropdown (visible when clustering is active):
543+
- **Cluster-wide** (default) — any node can run the scheduled workflow
544+
- **Specific node** — only that node's scheduler picks it up
545+
546+
**Gateways (Telegram, Discord):** in Admin > Config, set `telegram.node` or `discord.node` to a node name. Only that node will connect the bot. Leave empty for cluster-wide (single-node default). This prevents multiple nodes from polling the same bot token — which causes API conflicts.
547+
548+
### Environment Variables
549+
550+
| Variable | Description | Example |
551+
|---|---|---|
552+
| `NODE_NAME` | BEAM node name. Use `.` for long names (recommended) | `alexclaw@node1.local` |
553+
| `CLUSTER_COOKIE` | Shared secret for inter-node authentication | `your_random_secret` |
554+
555+
### Security
556+
557+
- `CLUSTER_COOKIE` is the authentication mechanism between nodes — treat it like `SECRET_KEY_BASE`
558+
- EPMD (port 4369) must be reachable between nodes but should NOT be exposed publicly
559+
- Use firewall rules or private networks to restrict inter-node traffic
560+
561+
---
562+
497563
## Running on a VPS / Cloud Server
498564

499565
AlexClaw is designed to run on a local machine, but works fine on a VPS too. Telegram polling is outbound-only, so no inbound ports are needed for the bot itself.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ AlexClaw monitors the world (RSS feeds, web sources, GitHub repositories, APIs),
2626
- **Persistent Memory with Semantic Search** — PostgreSQL + pgvector for knowledge storage. Deduplication by URL. Hybrid search combines vector cosine similarity and keyword matching — vector results are prioritized, keyword results fill gaps for exact matches. Embeddings are generated asynchronously via the LLM router (Gemini `gemini-embedding-001`, Ollama `nomic-embed-text`, or any OpenAI-compatible endpoint). 768-dimension vectors with HNSW index. All skills that store knowledge auto-embed in the background.
2727
- **Knowledge Base RAG** — Separate `knowledge_entries` table for documentation and reference material, isolated from news/conversation memory. Scraper skills fetch, chunk, and embed documentation from hexdocs.pm, Erlang/OTP source (GitHub), Elixir stdlib source, Learn You Some Erlang, and existing skill code. Chat integrates both Knowledge and Memory search with a context source selector. ~7200 embeddings across 6 knowledge kinds.
2828
- **Cron Scheduler** — Quantum-based. Jobs defined in config or DB.
29+
- **Multi-Node BEAM Clustering** — Multiple AlexClaw instances connected via Erlang distribution. Each node runs its own executor independently; nodes exchange workflow outputs via `send_to_workflow` and `receive_from_workflow` skills. Auto-discovery, node status monitoring, and per-workflow node assignment from the admin UI. `docker-compose_swarm.yml` included for local multi-node testing.
2930

3031
### Skills
3132

@@ -48,6 +49,8 @@ AlexClaw monitors the world (RSS feeds, web sources, GitHub repositories, APIs),
4849
| `shell` | Execute whitelisted OS commands for container introspection (2FA-gated) |
4950
| `web_automation` | Browser automation via headless Playwright sidecar (**experimental**) |
5051
| `coder` | Generate dynamic skills from natural language via local LLM |
52+
| `send_to_workflow` | Send data to a workflow on another BEAM node |
53+
| `receive_from_workflow` | Gate: accepts remote triggers when placed as step 1 |
5154
| `hexdocs_scraper` | Scrape hexdocs.pm docs into knowledge base embeddings (dynamic) |
5255
| `erlang_docs_scraper` | Fetch Erlang/OTP docs from GitHub into knowledge base (dynamic) |
5356
| `lyse_scraper` | Scrape Learn You Some Erlang chapters into knowledge base (dynamic) |

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ Execute OS commands inside the AlexClaw container via Telegram/Discord. 5-layer
5959

6060
Local LLM generates dynamic skills from natural language goals via `/coder <goal>`. SkillAPI extended with `:skill_write`, `:skill_manage`, `:workflow_manage` permissions. Retry loop with error feedback, knowledge base RAG context, optional workflow creation. Zero cloud API cost (always uses `tier: :local`). Generated code passes full validation pipeline (namespace, behaviour, permissions). See [SELF_AWARENESS.md](docs/SELF_AWARENESS.md).
6161

62+
### ~~Multi-Node BEAM Clustering~~ ✅ Completed (v0.3.8)
63+
64+
Multiple AlexClaw instances connected via Erlang distribution exchange workflow outputs over BEAM. Each node runs its own sequential executor — no parallel step changes. ClusterManager GenServer handles auto-registration on connect, node monitoring (`:nodeup`/`:nodedown`), and remote workflow triggers via `:rpc.call`. Two new core skills: `send_to_workflow` (sends data to a workflow on another node, 5s default timeout) and `receive_from_workflow` (gate skill — must be step 1 to accept remote triggers, optional `allowed_nodes` ACL). Cluster admin UI page with node status and ping. Workflow "Run on" dropdown for node assignment (cluster-wide or pinned). `docker-compose_swarm.yml` for multi-node testing with long-name distribution (`alexclaw@nodeN.local`). EPMD bundled in runtime image.
65+
6266
---
6367

6468
## Someday

SECURITY.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ Set `github.webhook_secret` in Admin > Config (GitHub category).
5252

5353
---
5454

55+
## Inter-Node Authentication (Clustering)
56+
57+
Multi-node clusters authenticate via BEAM's distributed Erlang protocol:
58+
59+
- All nodes must share the same `CLUSTER_COOKIE` (set via environment variable)
60+
- EPMD (Erlang Port Mapper Daemon) on port 4369 coordinates node discovery
61+
- Nodes without the correct cookie cannot join the cluster or trigger remote workflows
62+
- The `receive_from_workflow` gate skill provides an additional per-workflow access control layer via optional `allowed_nodes` config
63+
64+
**Hardening recommendations:**
65+
- Generate `CLUSTER_COOKIE` with `openssl rand -base64 32` — treat it like `SECRET_KEY_BASE`
66+
- EPMD port (4369) and BEAM distribution ports (dynamic, high range) should NOT be exposed to the internet
67+
- Restrict inter-node traffic to private networks, VPCs, or Docker networks
68+
- When running across machines, use VPN or SSH tunnels between Docker hosts
69+
70+
---
71+
5572
## Shell Skill (Container Introspection)
5673

5774
The `/shell` command allows the owner to run OS commands inside the container

config/runtime.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ if config_env() == :prod do
3030
ollama_enabled: System.get_env("OLLAMA_ENABLED") == "true",
3131
ollama_host: System.get_env("OLLAMA_HOST", "http://localhost:11434"),
3232
ollama_model: System.get_env("OLLAMA_MODEL", "llama3.2")
33+
34+
config :alex_claw, :node_name, System.get_env("NODE_NAME")
3335
end

docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ services:
1919
context: .
2020
args:
2121
BUILD_NUMBER: ${BUILD_NUMBER:-0}
22+
hostname: node1.local
2223
restart: unless-stopped
2324
depends_on:
2425
db:
@@ -57,6 +58,10 @@ services:
5758
WEB_AUTOMATOR_ENABLED: ${WEB_AUTOMATOR_ENABLED:-false}
5859
WEB_AUTOMATOR_HOST: ${WEB_AUTOMATOR_HOST:-http://web-automator:6900}
5960

61+
# Clustering
62+
NODE_NAME: ${NODE_NAME:-alexclaw@node1.local}
63+
CLUSTER_COOKIE: ${CLUSTER_COOKIE:-alexclaw_default}
64+
6065
# Dynamic skills
6166
SKILLS_DIR: /app/skills
6267
volumes:

docker-compose_swarm.yml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
services:
2+
db:
3+
image: pgvector/pgvector:pg17
4+
restart: unless-stopped
5+
environment:
6+
POSTGRES_USER: ${DATABASE_USERNAME:-alexclaw}
7+
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
8+
POSTGRES_DB: alex_claw_prod
9+
volumes:
10+
- pgdata:/var/lib/postgresql/data
11+
healthcheck:
12+
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME:-alexclaw}"]
13+
interval: 5s
14+
timeout: 3s
15+
retries: 5
16+
17+
node1:
18+
build:
19+
context: .
20+
args:
21+
BUILD_NUMBER: ${BUILD_NUMBER:-0}
22+
hostname: node1.local
23+
restart: unless-stopped
24+
depends_on:
25+
db:
26+
condition: service_healthy
27+
ports:
28+
- "5001:5001"
29+
extra_hosts:
30+
- "host.docker.internal:host-gateway"
31+
environment:
32+
DATABASE_USERNAME: ${DATABASE_USERNAME:-alexclaw}
33+
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
34+
DATABASE_HOSTNAME: db
35+
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
36+
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
37+
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
38+
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
39+
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
40+
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
41+
OLLAMA_ENABLED: ${OLLAMA_ENABLED:-false}
42+
OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434}
43+
OLLAMA_MODEL: ${OLLAMA_MODEL:-}
44+
LMSTUDIO_ENABLED: ${LMSTUDIO_ENABLED:-false}
45+
LMSTUDIO_HOST: ${LMSTUDIO_HOST:-http://host.docker.internal:1234}
46+
LMSTUDIO_MODEL: ${LMSTUDIO_MODEL:-}
47+
NODE_NAME: alexclaw@node1.local
48+
CLUSTER_COOKIE: alexclaw_swarm
49+
SKILLS_DIR: /app/skills
50+
volumes:
51+
- skills_data:/app/skills
52+
53+
node2:
54+
build:
55+
context: .
56+
args:
57+
BUILD_NUMBER: ${BUILD_NUMBER:-0}
58+
hostname: node2.local
59+
restart: unless-stopped
60+
depends_on:
61+
db:
62+
condition: service_healthy
63+
ports:
64+
- "5002:5001"
65+
extra_hosts:
66+
- "host.docker.internal:host-gateway"
67+
environment:
68+
DATABASE_USERNAME: ${DATABASE_USERNAME:-alexclaw}
69+
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
70+
DATABASE_HOSTNAME: db
71+
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
72+
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
73+
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
74+
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID}
75+
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
76+
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
77+
OLLAMA_ENABLED: ${OLLAMA_ENABLED:-false}
78+
OLLAMA_HOST: ${OLLAMA_HOST:-http://host.docker.internal:11434}
79+
OLLAMA_MODEL: ${OLLAMA_MODEL:-}
80+
LMSTUDIO_ENABLED: ${LMSTUDIO_ENABLED:-false}
81+
LMSTUDIO_HOST: ${LMSTUDIO_HOST:-http://host.docker.internal:1234}
82+
LMSTUDIO_MODEL: ${LMSTUDIO_MODEL:-}
83+
NODE_NAME: alexclaw@node2.local
84+
CLUSTER_COOKIE: alexclaw_swarm
85+
SKILLS_DIR: /app/skills
86+
volumes:
87+
- skills_data:/app/skills
88+
89+
volumes:
90+
pgdata:
91+
skills_data:

0 commit comments

Comments
 (0)