summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-21 23:51:11 +0000
committerGitHub <noreply@github.com>2026-02-21 23:51:11 +0000
commit0523765af84492640928d571f481e17b26008b13 (patch)
tree644e0bac90c1945120df27dea36d18c81f4470e9
parentd670dcb72984cfa483063d161bb468704038895c (diff)
downloadsoryu-0523765af84492640928d571f481e17b26008b13.tar.gz
soryu-0523765af84492640928d571f481e17b26008b13.zip
feat: Add daemon health monitoring page, downloads & K8s support (#76)
* feat: soryu-co/soryu - makima: Add server-side daemon binary download endpoint * feat: soryu-co/soryu - makima: Create Kubernetes daemon manifests and Dockerfile * feat: soryu-co/soryu - makima: Create dedicated Daemons page with health monitoring UI * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Integrate daemon platform availability into frontend downloads
-rw-r--r--.github/workflows/daemon-image.yml62
-rw-r--r--.github/workflows/release.yml55
-rw-r--r--Dockerfile5
-rw-r--r--k8s/daemon/Dockerfile78
-rw-r--r--k8s/daemon/README.md182
-rw-r--r--k8s/daemon/configmap.yaml14
-rw-r--r--k8s/daemon/deployment.yaml106
-rw-r--r--k8s/daemon/hpa.yaml42
-rw-r--r--k8s/daemon/kustomization.yaml14
-rw-r--r--k8s/daemon/secret.yaml31
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/lib/api.ts36
-rw-r--r--makima/frontend/src/main.tsx9
-rw-r--r--makima/frontend/src/routes/daemons.tsx370
-rw-r--r--makima/frontend/src/routes/settings.tsx185
-rw-r--r--makima/src/server/handlers/daemon_download.rs163
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs5
18 files changed, 1178 insertions, 181 deletions
diff --git a/.github/workflows/daemon-image.yml b/.github/workflows/daemon-image.yml
new file mode 100644
index 0000000..2501dab
--- /dev/null
+++ b/.github/workflows/daemon-image.yml
@@ -0,0 +1,62 @@
+name: Daemon Image
+
+on:
+ push:
+ tags:
+ - 'v*'
+ branches:
+ - main
+ paths:
+ - 'makima/**'
+ - 'vendor/**'
+ - 'k8s/daemon/Dockerfile'
+ - 'Cargo.*'
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: soryu-co/makima-daemon
+
+jobs:
+ build-and-push:
+ name: Build and Push Daemon Image
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: k8s/daemon/Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ca9aae0..42d4241 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,6 +24,10 @@ jobs:
os: macos-15-intel
artifact_name: makima
asset_name: makima-${{ github.ref_name }}-macos-x86_64
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ artifact_name: makima
+ asset_name: makima-${{ github.ref_name }}-linux-arm64
- target: aarch64-apple-darwin
os: macos-14
artifact_name: makima
@@ -44,6 +48,13 @@ jobs:
sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev
+ - name: Install cross-compilation tools (Linux ARM64)
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ run: |
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+ echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
+ echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu" >> $GITHUB_ENV
+
- name: Build release binary
working-directory: makima
run: cargo build --release --target ${{ matrix.target }}
@@ -65,9 +76,41 @@ jobs:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}.tar.gz
+ embed-daemon-binaries:
+ name: Package Daemon Binaries
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download Linux x86_64 artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: makima-${{ github.ref_name }}-linux-x86_64
+ path: daemon-binaries
+
+ - name: Download Linux ARM64 artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: makima-${{ github.ref_name }}-linux-arm64
+ path: daemon-binaries
+
+ - name: Extract and repackage daemon binaries
+ run: |
+ mkdir -p daemon-extracted/linux-x86_64 daemon-extracted/linux-arm64
+
+ tar xzf daemon-binaries/makima-${{ github.ref_name }}-linux-x86_64.tar.gz -C daemon-extracted/linux-x86_64
+ tar xzf daemon-binaries/makima-${{ github.ref_name }}-linux-arm64.tar.gz -C daemon-extracted/linux-arm64
+
+ tar czvf daemon-binaries.tar.gz -C daemon-extracted .
+
+ - name: Upload daemon binaries archive
+ uses: actions/upload-artifact@v4
+ with:
+ name: daemon-binaries
+ path: daemon-binaries.tar.gz
+
release:
name: Create Release
- needs: build
+ needs: [build, embed-daemon-binaries]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -111,6 +154,11 @@ jobs:
tar xzf makima-${{ github.ref_name }}-linux-x86_64.tar.gz
sudo mv makima /usr/local/bin/
+ # Linux ARM64
+ curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-linux-arm64.tar.gz
+ tar xzf makima-${{ github.ref_name }}-linux-arm64.tar.gz
+ sudo mv makima /usr/local/bin/
+
# macOS Intel
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-macos-x86_64.tar.gz
tar xzf makima-${{ github.ref_name }}-macos-x86_64.tar.gz
@@ -158,6 +206,11 @@ jobs:
tar xzf makima-${{ github.ref_name }}-linux-x86_64.tar.gz
sudo mv makima /usr/local/bin/
+ # Linux ARM64
+ curl -LO https://github.com/soryu-co/makima/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-linux-arm64.tar.gz
+ tar xzf makima-${{ github.ref_name }}-linux-arm64.tar.gz
+ sudo mv makima /usr/local/bin/
+
# macOS Intel
curl -LO https://github.com/soryu-co/makima/releases/download/${{ github.ref_name }}/makima-${{ github.ref_name }}-macos-x86_64.tar.gz
tar xzf makima-${{ github.ref_name }}-macos-x86_64.tar.gz
diff --git a/Dockerfile b/Dockerfile
index f33045c..a8a9245 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -33,6 +33,11 @@ COPY voices ./voices
RUN cargo build --release --package makima --bin makima
RUN cp /app/target/release/makima /makima
+# Embed daemon binary for download endpoint
+RUN mkdir -p /app/daemon-binaries
+RUN cp /app/target/release/makima /app/daemon-binaries/makima-linux-x86_64
+ENV DAEMON_BINARIES_DIR=/app/daemon-binaries
+
# Clean up build artifacts to reduce image size
RUN rm -rf /app/target /app/makima/src /app/vendor /app/tools /usr/local/cargo/registry
diff --git a/k8s/daemon/Dockerfile b/k8s/daemon/Dockerfile
new file mode 100644
index 0000000..1c1ccd1
--- /dev/null
+++ b/k8s/daemon/Dockerfile
@@ -0,0 +1,78 @@
+# ==============================================================================
+# Makima Daemon - Lightweight Container Image
+# ==============================================================================
+# This Dockerfile builds a minimal image for running `makima daemon` in
+# Kubernetes. Unlike the full server image (which includes ML models), this
+# image contains only the makima binary and the tools it needs to execute
+# tasks: git, gh CLI, curl, and SSH client.
+# ==============================================================================
+
+# ---------- Builder stage ----------
+FROM rust:1.91-bookworm AS builder
+
+WORKDIR /app
+
+# Install build dependencies
+RUN apt-get update && apt-get install -y \
+ pkg-config \
+ libssl-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy workspace files
+COPY Cargo.toml Cargo.lock ./
+COPY makima ./makima
+COPY vendor ./vendor
+COPY tools/stt-client ./tools/stt-client
+
+# Build release binary
+RUN cargo build --release --package makima --bin makima
+
+# ---------- Runtime stage ----------
+FROM debian:bookworm-slim
+
+# Install runtime dependencies:
+# - ca-certificates: TLS certificate verification
+# - libssl3: OpenSSL runtime for TLS connections
+# - git: Git operations (clone, worktree, push, etc.)
+# - curl: Health checks and HTTP requests
+# - openssh-client: SSH key-based git authentication
+# - jq: JSON processing in scripts
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ libssl3 \
+ git \
+ curl \
+ openssh-client \
+ jq \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install GitHub CLI (gh)
+RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
+ && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ > /etc/apt/sources.list.d/github-cli.list \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends gh \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy the built binary from the builder stage
+COPY --from=builder /app/target/release/makima /usr/local/bin/makima
+
+# Create application directories
+# - /app/workdir: Working directory for git worktrees
+# - /app/data: Local database and state
+RUN mkdir -p /app/workdir /app/data /root/.makima
+
+# Set environment defaults
+ENV RUST_LOG=makima=info
+ENV MAKIMA_DAEMON_WORKTREE_BASEDIR=/app/workdir
+ENV MAKIMA_DAEMON_WORKTREE_REPOSDIR=/app/workdir/repos
+ENV MAKIMA_DAEMON_LOCALDB_PATH=/app/data/daemon.db
+ENV MAKIMA_DAEMON_REPOS_HOMEDIR=/app/workdir/home
+ENV HOME=/root
+
+WORKDIR /app
+
+ENTRYPOINT ["makima"]
+CMD ["daemon"]
diff --git a/k8s/daemon/README.md b/k8s/daemon/README.md
new file mode 100644
index 0000000..79c8f96
--- /dev/null
+++ b/k8s/daemon/README.md
@@ -0,0 +1,182 @@
+# Makima Daemon — Kubernetes Deployment
+
+Run makima daemon workers in Kubernetes. Each daemon pod connects to the Makima server via WebSocket, authenticates with an API key, and executes tasks that involve git operations and Claude Code subprocesses.
+
+## Prerequisites
+
+- Kubernetes 1.25+
+- `kubectl` configured for your cluster
+- A Makima server accessible from inside the cluster
+- A Makima API key (generate one from the Makima dashboard)
+- *(Optional)* A GitHub personal access token for `gh` CLI operations
+- *(Optional)* SSH keys for git-over-SSH authentication
+
+## Quick Start
+
+### 1. Build the daemon image
+
+From the repository root:
+
+```bash
+docker build -f k8s/daemon/Dockerfile -t ghcr.io/soryu-co/makima-daemon:latest .
+docker push ghcr.io/soryu-co/makima-daemon:latest
+```
+
+### 2. Configure secrets
+
+Edit `secret.yaml` with your actual credentials, or create the secret directly:
+
+```bash
+kubectl create secret generic makima-daemon-secrets \
+ --from-literal=api-key=YOUR_API_KEY \
+ --from-literal=github-token=YOUR_GITHUB_TOKEN
+```
+
+### 3. Configure the server URL
+
+Edit `configmap.yaml` to point to your Makima server:
+
+```yaml
+data:
+ server-url: "wss://api.makima.jp" # or your self-hosted server
+ log-level: "makima=info"
+```
+
+### 4. Deploy with Kustomize
+
+```bash
+kubectl apply -k k8s/daemon/
+```
+
+Or apply individual manifests:
+
+```bash
+kubectl apply -f k8s/daemon/configmap.yaml
+kubectl apply -f k8s/daemon/secret.yaml
+kubectl apply -f k8s/daemon/deployment.yaml
+kubectl apply -f k8s/daemon/hpa.yaml
+```
+
+### 5. Verify
+
+```bash
+kubectl get pods -l app=makima-daemon
+kubectl logs -l app=makima-daemon -f
+```
+
+## Scaling
+
+### Horizontal Pod Autoscaler
+
+The included `hpa.yaml` scales the daemon deployment between 1 and 10 replicas based on:
+
+| Metric | Target | Description |
+|--------|--------|-------------|
+| CPU | 70% utilization | Scales up when tasks are CPU-bound |
+| Memory | 80% utilization | Scales up when worktrees consume memory |
+
+Scale-up adds up to 2 pods per minute; scale-down removes 1 pod every 2 minutes with a 5-minute stabilization window to avoid flapping.
+
+### Manual scaling
+
+```bash
+# Scale to 5 replicas
+kubectl scale deployment makima-daemon --replicas=5
+
+# Or patch the HPA limits
+kubectl patch hpa makima-daemon -p '{"spec":{"maxReplicas":20}}'
+```
+
+## SSH Keys
+
+To use SSH-based git authentication, create a secret with your SSH key:
+
+```bash
+kubectl create secret generic makima-daemon-ssh \
+ --from-file=id_ed25519=$HOME/.ssh/id_ed25519 \
+ --from-file=known_hosts=$HOME/.ssh/known_hosts
+```
+
+The deployment mounts this at `/root/.ssh` (read-only, mode 0600).
+
+> **Tip:** For GitHub, you can use a deploy key or a personal SSH key. Make sure
+> `github.com` is in your `known_hosts` file.
+
+## Environment Variables
+
+All daemon configuration can be controlled via environment variables. The deployment
+sources these from the ConfigMap and Secret, but you can add more in the deployment spec.
+
+| Variable | Source | Description |
+|----------|--------|-------------|
+| `MAKIMA_API_KEY` | Secret | **(Required)** API key for server authentication |
+| `MAKIMA_DAEMON_SERVER_URL` | ConfigMap | WebSocket URL of the Makima server |
+| `RUST_LOG` | ConfigMap | Log level filter (e.g., `makima=info`, `makima=debug`) |
+| `GITHUB_TOKEN` | Secret | GitHub PAT for `gh` CLI and HTTPS git auth |
+| `GH_TOKEN` | Secret | Alias for `GITHUB_TOKEN` (used by `gh` CLI) |
+| `MAKIMA_DAEMON_WORKTREE_BASEDIR` | Dockerfile | Base dir for worktrees (default: `/app/workdir`) |
+| `MAKIMA_DAEMON_WORKTREE_REPOSDIR` | Dockerfile | Cached repo clones (default: `/app/workdir/repos`) |
+| `MAKIMA_DAEMON_LOCALDB_PATH` | Dockerfile | SQLite database path (default: `/app/data/daemon.db`) |
+| `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` | — | Max concurrent tasks per daemon (default: 10) |
+| `MAKIMA_DAEMON_PROCESS_CLAUDECOMMAND` | — | Path to Claude Code CLI (default: `claude`) |
+| `MAKIMA_DAEMON_SERVER_HEARTBEATINTERVALSECS` | — | WebSocket heartbeat interval (default: 30) |
+| `MAKIMA_DAEMON_SERVER_RECONNECTINTERVALSECS` | — | Reconnect delay on disconnect (default: 5) |
+
+## Resource Tuning
+
+Default resource requests/limits:
+
+```yaml
+resources:
+ requests:
+ memory: "512Mi"
+ cpu: "500m"
+ limits:
+ memory: "2Gi"
+ cpu: "2000m"
+```
+
+**When to increase resources:**
+- Tasks involve large repositories — increase `memory` limits and `workdir` volume size
+- Many concurrent tasks per pod — increase `cpu` limits and `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS`
+- Large diffs or many worktrees — increase the `emptyDir.sizeLimit` on the `workdir` volume
+
+## Troubleshooting
+
+### Pod is CrashLoopBackOff
+
+```bash
+kubectl logs <pod-name> --previous
+```
+
+Common causes:
+- **"API key is required"** — the `makima-daemon-secrets` secret is missing or `api-key` is empty
+- **"Authentication failed"** — the API key is invalid or the server URL is wrong
+- **DNS resolution failure** — the server URL hostname is not resolvable from inside the cluster
+
+### Daemon connects but no tasks execute
+
+- Verify the daemon appears in the Makima dashboard under connected daemons
+- Check that `GITHUB_TOKEN` is set if tasks involve GitHub repositories
+- Ensure the `claude` CLI is available inside the container (it should be, but custom images may differ)
+
+### Worktree disk pressure
+
+The `workdir` volume is an `emptyDir` with a 10Gi limit. If tasks create many large worktrees:
+
+```bash
+# Check disk usage inside a pod
+kubectl exec <pod-name> -- du -sh /app/workdir/*
+```
+
+Increase the limit in `deployment.yaml` or switch to a PersistentVolumeClaim for larger workloads.
+
+### Viewing daemon logs
+
+```bash
+# Follow logs from all daemon pods
+kubectl logs -l app=makima-daemon -f --tail=100
+
+# Debug-level logging
+kubectl set env deployment/makima-daemon RUST_LOG=makima=debug
+```
diff --git a/k8s/daemon/configmap.yaml b/k8s/daemon/configmap.yaml
new file mode 100644
index 0000000..b75ca88
--- /dev/null
+++ b/k8s/daemon/configmap.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: makima-daemon-config
+ labels:
+ app: makima-daemon
+ app.kubernetes.io/name: makima-daemon
+ app.kubernetes.io/component: daemon
+ app.kubernetes.io/part-of: makima
+data:
+ # WebSocket URL of the Makima server (wss:// for TLS, ws:// for plain)
+ server-url: "wss://api.makima.jp"
+ # Log level (trace, debug, info, warn, error)
+ log-level: "makima=info"
diff --git a/k8s/daemon/deployment.yaml b/k8s/daemon/deployment.yaml
new file mode 100644
index 0000000..b0f5fb3
--- /dev/null
+++ b/k8s/daemon/deployment.yaml
@@ -0,0 +1,106 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: makima-daemon
+ labels:
+ app: makima-daemon
+ app.kubernetes.io/name: makima-daemon
+ app.kubernetes.io/component: daemon
+ app.kubernetes.io/part-of: makima
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: makima-daemon
+ template:
+ metadata:
+ labels:
+ app: makima-daemon
+ app.kubernetes.io/name: makima-daemon
+ app.kubernetes.io/component: daemon
+ app.kubernetes.io/part-of: makima
+ spec:
+ terminationGracePeriodSeconds: 30
+ containers:
+ - name: makima-daemon
+ image: ghcr.io/soryu-co/makima-daemon:latest
+ imagePullPolicy: Always
+ resources:
+ requests:
+ memory: "512Mi"
+ cpu: "500m"
+ limits:
+ memory: "2Gi"
+ cpu: "2000m"
+ env:
+ # --- Secrets ---
+ - name: MAKIMA_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: makima-daemon-secrets
+ key: api-key
+ - name: GITHUB_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: makima-daemon-secrets
+ key: github-token
+ optional: true
+ - name: GH_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: makima-daemon-secrets
+ key: github-token
+ optional: true
+ # --- ConfigMap ---
+ - name: MAKIMA_DAEMON_SERVER_URL
+ valueFrom:
+ configMapKeyRef:
+ name: makima-daemon-config
+ key: server-url
+ - name: RUST_LOG
+ valueFrom:
+ configMapKeyRef:
+ name: makima-daemon-config
+ key: log-level
+ volumeMounts:
+ - name: workdir
+ mountPath: /app/workdir
+ - name: data
+ mountPath: /app/data
+ - name: ssh-keys
+ mountPath: /root/.ssh
+ readOnly: true
+ # Liveness probe: check that the makima daemon process is running
+ livenessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - pgrep -f "makima daemon" > /dev/null
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ timeoutSeconds: 5
+ failureThreshold: 3
+ # Readiness probe: same check — daemon is ready when the process is alive
+ readinessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - pgrep -f "makima daemon" > /dev/null
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+ volumes:
+ - name: workdir
+ emptyDir:
+ sizeLimit: 10Gi
+ - name: data
+ emptyDir:
+ sizeLimit: 1Gi
+ - name: ssh-keys
+ secret:
+ secretName: makima-daemon-ssh
+ optional: true
+ defaultMode: 0600
diff --git a/k8s/daemon/hpa.yaml b/k8s/daemon/hpa.yaml
new file mode 100644
index 0000000..e529d75
--- /dev/null
+++ b/k8s/daemon/hpa.yaml
@@ -0,0 +1,42 @@
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: makima-daemon
+ labels:
+ app: makima-daemon
+ app.kubernetes.io/name: makima-daemon
+ app.kubernetes.io/component: daemon
+ app.kubernetes.io/part-of: makima
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: makima-daemon
+ minReplicas: 1
+ maxReplicas: 10
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 70
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 80
+ behavior:
+ scaleUp:
+ stabilizationWindowSeconds: 60
+ policies:
+ - type: Pods
+ value: 2
+ periodSeconds: 60
+ scaleDown:
+ stabilizationWindowSeconds: 300
+ policies:
+ - type: Pods
+ value: 1
+ periodSeconds: 120
diff --git a/k8s/daemon/kustomization.yaml b/k8s/daemon/kustomization.yaml
new file mode 100644
index 0000000..a4372cc
--- /dev/null
+++ b/k8s/daemon/kustomization.yaml
@@ -0,0 +1,14 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+metadata:
+ name: makima-daemon
+
+commonLabels:
+ app.kubernetes.io/part-of: makima
+
+resources:
+ - deployment.yaml
+ - configmap.yaml
+ - secret.yaml
+ - hpa.yaml
diff --git a/k8s/daemon/secret.yaml b/k8s/daemon/secret.yaml
new file mode 100644
index 0000000..74b0e13
--- /dev/null
+++ b/k8s/daemon/secret.yaml
@@ -0,0 +1,31 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: makima-daemon-secrets
+ labels:
+ app: makima-daemon
+ app.kubernetes.io/name: makima-daemon
+ app.kubernetes.io/component: daemon
+ app.kubernetes.io/part-of: makima
+type: Opaque
+stringData:
+ # REQUIRED: Your Makima API key for daemon authentication
+ api-key: "REPLACE_WITH_YOUR_API_KEY"
+ # OPTIONAL: GitHub personal access token for gh CLI and git operations
+ github-token: "REPLACE_WITH_YOUR_GITHUB_TOKEN"
+---
+# Optional: SSH keys for git authentication over SSH
+# Create with: kubectl create secret generic makima-daemon-ssh \
+# --from-file=id_ed25519=/path/to/key \
+# --from-file=known_hosts=/path/to/known_hosts
+apiVersion: v1
+kind: Secret
+metadata:
+ name: makima-daemon-ssh
+ labels:
+ app: makima-daemon
+ app.kubernetes.io/name: makima-daemon
+ app.kubernetes.io/component: daemon
+ app.kubernetes.io/part-of: makima
+type: Opaque
+data: {}
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 4932427..9556458 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -15,6 +15,7 @@ const NAV_LINKS: NavLink[] = [
{ label: "Orders", href: "/orders", requiresAuth: true },
{ label: "Contracts", href: "/contracts", requiresAuth: true },
{ label: "Mesh", href: "/mesh", requiresAuth: true },
+ { label: "Daemons", href: "/daemons", requiresAuth: true },
{ label: "History", href: "/history", requiresAuth: true },
];
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 43eaa05..458b69d 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1222,6 +1222,42 @@ export async function restartDaemon(id: string): Promise<RestartDaemonResponse>
}
// =============================================================================
+// Daemon Platform Download
+// =============================================================================
+
+/** A daemon platform with its availability and download URL */
+export interface DaemonPlatform {
+ platform: string;
+ available: boolean;
+ downloadUrl: string;
+}
+
+/** Response from the list daemon platforms endpoint */
+export interface DaemonPlatformsResponse {
+ platforms: DaemonPlatform[];
+}
+
+/**
+ * List available daemon platforms and their download status.
+ * This is an unauthenticated endpoint.
+ */
+export async function listDaemonPlatforms(): Promise<DaemonPlatformsResponse> {
+ const res = await fetch(`${API_BASE}/api/v1/daemon/download/platforms`);
+ if (!res.ok) {
+ throw new Error(`Failed to list daemon platforms: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get the full download URL for a daemon binary.
+ * Returns the absolute URL including API_BASE for cross-origin usage.
+ */
+export function getDaemonDownloadUrl(platform: string): string {
+ return `${API_BASE}/api/v1/daemon/download/${platform}`;
+}
+
+// =============================================================================
// Mesh Chat Types for Task Orchestration
// =============================================================================
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index acc9afc..32c05ba 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -14,6 +14,7 @@ import FilesPage from "./routes/files";
import ContractsPage from "./routes/contracts";
import OrdersPage from "./routes/orders";
import MeshPage from "./routes/mesh";
+import DaemonsPage from "./routes/daemons";
import HistoryPage from "./routes/history";
import LoginPage from "./routes/login";
import SettingsPage from "./routes/settings";
@@ -113,6 +114,14 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
+ path="/daemons"
+ element={
+ <ProtectedRoute>
+ <DaemonsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
path="/history"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/daemons.tsx b/makima/frontend/src/routes/daemons.tsx
new file mode 100644
index 0000000..f551543
--- /dev/null
+++ b/makima/frontend/src/routes/daemons.tsx
@@ -0,0 +1,370 @@
+import { useState, useEffect, useCallback } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import {
+ listDaemons,
+ restartDaemon,
+ listDaemonPlatforms,
+ API_BASE,
+ type Daemon,
+ type DaemonListResponse,
+ type DaemonPlatform,
+ type RestartDaemonResponse,
+} from "../lib/api";
+
+// =============================================================================
+// Section Header Component
+// =============================================================================
+
+function SectionHeader({ children }: { children: React.ReactNode }) {
+ return (
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa] mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ {children}
+ </h2>
+ );
+}
+
+// =============================================================================
+// Alert Component
+// =============================================================================
+
+function ErrorAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+// =============================================================================
+// Daemons Page
+// =============================================================================
+
+export default function DaemonsPage() {
+ const { isAuthenticated, isAuthConfigured } = useAuth();
+ const navigate = useNavigate();
+
+ // Daemon state
+ const [daemons, setDaemons] = useState<Daemon[]>([]);
+ const [daemonsLoading, setDaemonsLoading] = useState(true);
+ const [daemonsError, setDaemonsError] = useState<string | null>(null);
+ const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null);
+ const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null);
+
+ // Platform availability state
+ const [platforms, setPlatforms] = useState<DaemonPlatform[]>([]);
+ const [platformsLoading, setPlatformsLoading] = useState(true);
+
+ // Redirect if not authenticated
+ useEffect(() => {
+ if (isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [isAuthConfigured, isAuthenticated, navigate]);
+
+ const loadDaemons = async () => {
+ try {
+ setDaemonsError(null);
+ const response: DaemonListResponse = await listDaemons();
+ setDaemons(response.daemons);
+ } catch (err) {
+ setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons");
+ } finally {
+ setDaemonsLoading(false);
+ }
+ };
+
+ const handleRestartDaemon = async (id: string) => {
+ try {
+ setRestartingDaemonId(id);
+ setDaemonsError(null);
+ const _response: RestartDaemonResponse = await restartDaemon(id);
+ // Daemon will restart, so refresh the list after a short delay
+ setTimeout(() => {
+ loadDaemons();
+ }, 2000);
+ } catch (err) {
+ setDaemonsError(err instanceof Error ? err.message : "Failed to restart daemon");
+ } finally {
+ setRestartingDaemonId(null);
+ setRestartConfirmDaemonId(null);
+ }
+ };
+
+ // Friendly labels for platform identifiers
+ const platformLabels: Record<string, string> = {
+ "linux-x86_64": "Linux (Intel/AMD)",
+ "linux-arm64": "Linux (ARM64)",
+ "macos-x86_64": "macOS (Intel)",
+ "macos-arm64": "macOS (Apple Silicon)",
+ };
+
+ const loadPlatforms = useCallback(async () => {
+ try {
+ setPlatformsLoading(true);
+ const response = await listDaemonPlatforms();
+ setPlatforms(response.platforms);
+ } catch {
+ // Fallback: show all platforms as unavailable if API endpoint is missing
+ setPlatforms([
+ { platform: "linux-x86_64", available: false, downloadUrl: "/api/v1/daemon/download/linux-x86_64" },
+ { platform: "linux-arm64", available: false, downloadUrl: "/api/v1/daemon/download/linux-arm64" },
+ { platform: "macos-x86_64", available: false, downloadUrl: "/api/v1/daemon/download/macos-x86_64" },
+ { platform: "macos-arm64", available: false, downloadUrl: "/api/v1/daemon/download/macos-arm64" },
+ ]);
+ } finally {
+ setPlatformsLoading(false);
+ }
+ }, []);
+
+ // Initial load
+ useEffect(() => {
+ loadDaemons();
+ loadPlatforms();
+ }, []);
+
+ // Auto-refresh daemons every 30 seconds
+ useEffect(() => {
+ const interval = setInterval(() => {
+ loadDaemons();
+ }, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+
+ <main className="flex-1 max-w-4xl mx-auto p-6 w-full">
+ {/* Page Header */}
+ <div className="mb-8">
+ <h1 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Daemons</h1>
+ <p className="text-[#7788aa] font-mono text-[10px] mt-2">
+ Daemons are worker processes that connect to Makima and execute tasks on your machines.
+ </p>
+ <div className="h-px bg-[rgba(117,170,252,0.35)] mt-2" />
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Left Column */}
+ <div className="space-y-6">
+ {/* Download Section */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Download Daemon</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-4">
+ Download the pre-compiled daemon binary for your platform. The daemon connects to the Makima server and executes tasks.
+ </p>
+
+ <div className="grid grid-cols-2 gap-2 mb-4">
+ {platformsLoading ? (
+ <p className="col-span-2 text-[#7788aa] font-mono text-[10px]">Loading platforms...</p>
+ ) : (
+ platforms.map((p) => (
+ <a
+ key={p.platform}
+ href={p.available ? `${API_BASE}${p.downloadUrl}` : undefined}
+ download={p.available ? true : undefined}
+ className={`flex flex-col items-center justify-center gap-1 p-3 border border-[rgba(117,170,252,0.25)] bg-[#0a1525] font-mono text-xs text-[#9bc3ff] transition-colors ${
+ p.available
+ ? "hover:bg-[#0d1f3a] cursor-pointer"
+ : "opacity-50 pointer-events-none"
+ }`}
+ >
+ <span className="text-[10px] uppercase tracking-wide">
+ {platformLabels[p.platform] || p.platform}
+ </span>
+ <span
+ className={`text-[10px] ${
+ p.available ? "text-green-400" : "text-[#556677]"
+ }`}
+ >
+ {p.available ? "Available" : "Not bundled"}
+ </span>
+ </a>
+ ))
+ )}
+ </div>
+
+ <div className="border-t border-[rgba(117,170,252,0.15)] pt-3">
+ <p className="text-[10px] font-mono uppercase tracking-wide text-[#8899aa] mb-2">
+ Quick Install
+ </p>
+ <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 break-all">
+ curl -fsSL https://raw.githubusercontent.com/soryu-co/soryu/master/install.sh | bash
+ </code>
+ </div>
+ </section>
+
+ {/* Daemon Setup */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Daemon Setup</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Set your API key as an environment variable:
+ </p>
+ <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3">
+ export MAKIMA_API_KEY="your-key"
+ </code>
+ <p className="text-[#7788aa] font-mono text-[10px]">
+ Then run: <code className="text-green-400">makima-daemon</code>
+ </p>
+ </section>
+
+ {/* Kubernetes Section */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Run in Kubernetes</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Deploy daemons as containers in Kubernetes for scalable task execution.
+ </p>
+
+ <p className="text-[10px] font-mono uppercase tracking-wide text-[#8899aa] mb-2">
+ Pull Container Image
+ </p>
+ <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-4 break-all">
+ docker pull ghcr.io/soryu-co/makima-daemon:latest
+ </code>
+
+ <p className="text-[10px] font-mono uppercase tracking-wide text-[#8899aa] mb-2">
+ Environment Variables
+ </p>
+ <div className="bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 space-y-1 mb-3">
+ <div>MAKIMA_API_KEY=<span className="text-[#7788aa]">"your-key"</span></div>
+ <div>MAKIMA_SERVER_URL=<span className="text-[#7788aa]">"https://your-server"</span></div>
+ <div>GITHUB_TOKEN=<span className="text-[#7788aa]">"ghp_..." </span><span className="text-[#556677]"># optional, for repo access</span></div>
+ </div>
+
+ <p className="text-[#556677] font-mono text-[10px]">
+ Kubernetes manifests available in the repository under <code className="text-[#75aafc]">k8s/daemon/</code>
+ </p>
+ </section>
+ </div>
+
+ {/* Right Column */}
+ <div className="space-y-6">
+ {/* Connected Daemons */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
+ Connected Daemons
+ </h2>
+ {daemons.length > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
+ </span>
+ )}
+ </div>
+ <button
+ onClick={loadDaemons}
+ disabled={daemonsLoading}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
+ title="Refresh"
+ >
+ {daemonsLoading ? "..." : "\u21BB"}
+ </button>
+ </div>
+
+ {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>}
+
+ {daemonsLoading && daemons.length === 0 ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : daemons.length === 0 ? (
+ <div className="text-center py-4">
+ <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p>
+ <p className="text-[#556677] font-mono text-[10px]">
+ Start a daemon to enable task execution
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {daemons.map((daemon) => (
+ <div
+ key={daemon.id}
+ className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
+ >
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ {daemon.hostname || "Unknown Host"}
+ </span>
+ <div className="flex items-center gap-2">
+ <span
+ className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${
+ daemon.status === "connected"
+ ? "text-green-400 border-green-700/50 bg-green-900/20"
+ : daemon.status === "unhealthy"
+ ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20"
+ : "text-[#8899aa] border-[rgba(117,170,252,0.25)]"
+ }`}
+ >
+ {daemon.status}
+ </span>
+ </div>
+ </div>
+ <div className="font-mono text-[10px] text-[#7788aa] space-y-1">
+ <div className="flex justify-between">
+ <span>Tasks</span>
+ <span className="text-[#9bc3ff]">
+ {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span>Connected</span>
+ <span className="text-[#75aafc]">
+ {new Date(daemon.connectedAt).toLocaleString()}
+ </span>
+ </div>
+ {daemon.machineId && (
+ <div className="flex justify-between">
+ <span>Machine</span>
+ <span className="text-[#556677] truncate ml-2" title={daemon.machineId}>
+ {daemon.machineId.substring(0, 16)}...
+ </span>
+ </div>
+ )}
+ </div>
+ {/* Restart Section */}
+ {daemon.status === "connected" && (
+ <div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]">
+ {restartConfirmDaemonId === daemon.id ? (
+ <div className="flex items-center justify-between gap-2">
+ <span className="text-[10px] font-mono text-yellow-400">
+ Restart daemon? Running tasks will be interrupted.
+ </span>
+ <div className="flex gap-2">
+ <button
+ onClick={() => setRestartConfirmDaemonId(null)}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1"
+ disabled={restartingDaemonId === daemon.id}
+ >
+ Cancel
+ </button>
+ <button
+ onClick={() => handleRestartDaemon(daemon.id)}
+ disabled={restartingDaemonId === daemon.id}
+ className="text-[10px] font-mono text-red-400 hover:text-red-300 px-2 py-1 border border-red-700/50 bg-red-900/20 disabled:opacity-50"
+ >
+ {restartingDaemonId === daemon.id ? "Restarting..." : "Confirm"}
+ </button>
+ </div>
+ </div>
+ ) : (
+ <button
+ onClick={() => setRestartConfirmDaemonId(daemon.id)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ &#x27F3; Restart Daemon
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
index b93ecbc..73537bd 100644
--- a/makima/frontend/src/routes/settings.tsx
+++ b/makima/frontend/src/routes/settings.tsx
@@ -10,13 +10,10 @@ import {
changePassword,
changeEmail,
deleteAccount,
- listDaemons,
- restartDaemon,
listRepositoryHistory,
deleteRepositoryHistory,
type ApiKeyInfo,
type CreateApiKeyResponse,
- type Daemon,
type RepositoryHistoryEntry,
} from "../lib/api";
@@ -303,13 +300,6 @@ export default function SettingsPage() {
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
- // Daemon state
- const [daemons, setDaemons] = useState<Daemon[]>([]);
- const [daemonsLoading, setDaemonsLoading] = useState(true);
- const [daemonsError, setDaemonsError] = useState<string | null>(null);
- const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null);
- const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null);
-
// Repository history state
const [repoHistory, setRepoHistory] = useState<RepositoryHistoryEntry[]>([]);
const [repoHistoryLoading, setRepoHistoryLoading] = useState(true);
@@ -318,18 +308,9 @@ export default function SettingsPage() {
useEffect(() => {
loadApiKey();
- loadDaemons();
loadRepoHistory();
}, []);
- // Auto-refresh daemons every 30 seconds
- useEffect(() => {
- const interval = setInterval(() => {
- loadDaemons();
- }, 30000);
- return () => clearInterval(interval);
- }, []);
-
const loadApiKey = async () => {
try {
setLoading(true);
@@ -343,18 +324,6 @@ export default function SettingsPage() {
}
};
- const loadDaemons = async () => {
- try {
- setDaemonsError(null);
- const response = await listDaemons();
- setDaemons(response.daemons);
- } catch (err) {
- setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons");
- } finally {
- setDaemonsLoading(false);
- }
- };
-
const loadRepoHistory = async () => {
try {
setRepoHistoryError(null);
@@ -379,23 +348,6 @@ export default function SettingsPage() {
}
};
- const handleRestartDaemon = async (id: string) => {
- try {
- setRestartingDaemonId(id);
- setDaemonsError(null);
- await restartDaemon(id);
- // Daemon will restart, so refresh the list after a short delay
- setTimeout(() => {
- loadDaemons();
- }, 2000);
- } catch (err) {
- setDaemonsError(err instanceof Error ? err.message : "Failed to restart daemon");
- } finally {
- setRestartingDaemonId(null);
- setRestartConfirmDaemonId(null);
- }
- };
-
const handleCreate = async () => {
try {
setActionLoading(true);
@@ -648,140 +600,15 @@ export default function SettingsPage() {
)}
</section>
- {/* Daemon Setup */}
+ {/* Daemons Link */}
<section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
- <SectionHeader>Daemon Setup</SectionHeader>
+ <SectionHeader>Daemons</SectionHeader>
<p className="text-[#7788aa] font-mono text-[10px] mb-3">
- Set your API key as an environment variable:
- </p>
- <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3">
- export MAKIMA_API_KEY="your-key"
- </code>
- <p className="text-[#7788aa] font-mono text-[10px]">
- Then run: <code className="text-green-400">makima-daemon</code>
+ Daemon management has moved to its own page.
</p>
- </section>
-
- {/* Connected Daemons */}
- <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
- <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
- <div className="flex items-center gap-2">
- <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
- Daemons
- </h2>
- {daemons.length > 0 && (
- <span className="text-[10px] font-mono text-[#556677]">
- ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
- </span>
- )}
- </div>
- <button
- onClick={loadDaemons}
- disabled={daemonsLoading}
- className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
- title="Refresh"
- >
- {daemonsLoading ? "..." : "↻"}
- </button>
- </div>
-
- {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>}
-
- {daemonsLoading && daemons.length === 0 ? (
- <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
- ) : daemons.length === 0 ? (
- <div className="text-center py-4">
- <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p>
- <p className="text-[#556677] font-mono text-[10px]">
- Start a daemon to enable task execution
- </p>
- </div>
- ) : (
- <div className="space-y-2">
- {daemons.map((daemon) => (
- <div
- key={daemon.id}
- className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
- >
- <div className="flex items-center justify-between mb-2">
- <span className="font-mono text-xs text-[#9bc3ff]">
- {daemon.hostname || "Unknown Host"}
- </span>
- <div className="flex items-center gap-2">
- <span
- className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${
- daemon.status === "connected"
- ? "text-green-400 border-green-700/50 bg-green-900/20"
- : daemon.status === "unhealthy"
- ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20"
- : "text-[#8899aa] border-[rgba(117,170,252,0.25)]"
- }`}
- >
- {daemon.status}
- </span>
- </div>
- </div>
- <div className="font-mono text-[10px] text-[#7788aa] space-y-1">
- <div className="flex justify-between">
- <span>Tasks</span>
- <span className="text-[#9bc3ff]">
- {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
- </span>
- </div>
- <div className="flex justify-between">
- <span>Connected</span>
- <span className="text-[#75aafc]">
- {new Date(daemon.connectedAt).toLocaleString()}
- </span>
- </div>
- {daemon.machineId && (
- <div className="flex justify-between">
- <span>Machine</span>
- <span className="text-[#556677] truncate ml-2" title={daemon.machineId}>
- {daemon.machineId.substring(0, 16)}...
- </span>
- </div>
- )}
- </div>
- {/* Restart Section */}
- {daemon.status === "connected" && (
- <div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]">
- {restartConfirmDaemonId === daemon.id ? (
- <div className="flex items-center justify-between gap-2">
- <span className="text-[10px] font-mono text-yellow-400">
- Restart daemon? Running tasks will be interrupted.
- </span>
- <div className="flex gap-2">
- <button
- onClick={() => setRestartConfirmDaemonId(null)}
- className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1"
- disabled={restartingDaemonId === daemon.id}
- >
- Cancel
- </button>
- <button
- onClick={() => handleRestartDaemon(daemon.id)}
- disabled={restartingDaemonId === daemon.id}
- className="text-[10px] font-mono text-red-400 hover:text-red-300 px-2 py-1 border border-red-700/50 bg-red-900/20 disabled:opacity-50"
- >
- {restartingDaemonId === daemon.id ? "Restarting..." : "Confirm"}
- </button>
- </div>
- </div>
- ) : (
- <button
- onClick={() => setRestartConfirmDaemonId(daemon.id)}
- className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]"
- >
- ⟳ Restart Daemon
- </button>
- )}
- </div>
- )}
- </div>
- ))}
- </div>
- )}
+ <a href="/daemons" className="text-[#75aafc] hover:text-[#9bc3ff] text-xs font-mono">
+ Go to Daemons &rarr;
+ </a>
</section>
{/* Repository History */}
diff --git a/makima/src/server/handlers/daemon_download.rs b/makima/src/server/handlers/daemon_download.rs
new file mode 100644
index 0000000..1575d8b
--- /dev/null
+++ b/makima/src/server/handlers/daemon_download.rs
@@ -0,0 +1,163 @@
+//! HTTP handlers for daemon binary downloads.
+//!
+//! Serves pre-compiled daemon binaries for download. Binaries are read from
+//! disk at a configurable path (default: `/app/daemon-binaries`), overridable
+//! via the `DAEMON_BINARIES_DIR` environment variable.
+
+use axum::{
+ extract::Path,
+ http::{header, StatusCode},
+ response::IntoResponse,
+ Json,
+};
+use serde::Serialize;
+
+/// Default directory where daemon binaries are stored.
+const DEFAULT_BINARIES_DIR: &str = "/app/daemon-binaries";
+
+/// Supported platforms for daemon binary downloads.
+const SUPPORTED_PLATFORMS: &[&str] = &[
+ "linux-x86_64",
+ "linux-arm64",
+ "macos-x86_64",
+ "macos-arm64",
+];
+
+/// Response for listing available daemon platforms.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PlatformInfo {
+ /// Platform identifier (e.g., "linux-x86_64")
+ pub platform: String,
+ /// Whether a binary is available for this platform
+ pub available: bool,
+ /// Download URL path for this platform
+ pub download_url: String,
+}
+
+/// Response for the list platforms endpoint.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListPlatformsResponse {
+ /// List of supported platforms and their availability
+ pub platforms: Vec<PlatformInfo>,
+}
+
+/// Get the binaries directory from environment or use default.
+fn get_binaries_dir() -> String {
+ std::env::var("DAEMON_BINARIES_DIR").unwrap_or_else(|_| DEFAULT_BINARIES_DIR.to_string())
+}
+
+/// Get the binary file path for a given platform.
+fn get_binary_path(platform: &str) -> std::path::PathBuf {
+ let dir = get_binaries_dir();
+ std::path::PathBuf::from(dir).join(format!("makima-{}", platform))
+}
+
+/// List available daemon platforms and their download availability.
+///
+/// Returns a list of all supported platforms with availability status
+/// based on whether the binary file exists on disk.
+pub async fn list_daemon_platforms() -> impl IntoResponse {
+ let mut platforms = Vec::with_capacity(SUPPORTED_PLATFORMS.len());
+
+ for &platform in SUPPORTED_PLATFORMS {
+ let path = get_binary_path(platform);
+ let available = path.exists();
+
+ platforms.push(PlatformInfo {
+ platform: platform.to_string(),
+ available,
+ download_url: format!("/api/v1/daemon/download/{}", platform),
+ });
+ }
+
+ (
+ StatusCode::OK,
+ Json(ListPlatformsResponse { platforms }),
+ )
+ .into_response()
+}
+
+/// Download a daemon binary for the specified platform.
+///
+/// Reads the binary from disk and returns it with appropriate headers
+/// for file download. Returns 404 if the binary is not available.
+pub async fn download_daemon(
+ Path(platform): Path<String>,
+) -> impl IntoResponse {
+ // Validate platform
+ if !SUPPORTED_PLATFORMS.contains(&platform.as_str()) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "code": "INVALID_PLATFORM",
+ "message": format!(
+ "Unsupported platform '{}'. Supported platforms: {}",
+ platform,
+ SUPPORTED_PLATFORMS.join(", ")
+ )
+ })),
+ )
+ .into_response();
+ }
+
+ let binary_path = get_binary_path(&platform);
+
+ // Read binary from disk
+ let binary_data = match tokio::fs::read(&binary_path).await {
+ Ok(data) => data,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({
+ "code": "BINARY_NOT_FOUND",
+ "message": format!(
+ "Daemon binary for platform '{}' is not available for download",
+ platform
+ )
+ })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!(
+ platform = %platform,
+ path = %binary_path.display(),
+ error = %e,
+ "Failed to read daemon binary"
+ );
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "code": "READ_ERROR",
+ "message": "Failed to read daemon binary"
+ })),
+ )
+ .into_response();
+ }
+ };
+
+ let filename = format!("makima-{}", platform);
+
+ // Return binary with download headers
+ (
+ StatusCode::OK,
+ [
+ (
+ header::CONTENT_TYPE,
+ "application/octet-stream".to_string(),
+ ),
+ (
+ header::CONTENT_DISPOSITION,
+ format!("attachment; filename=\"{}\"", filename),
+ ),
+ (
+ header::CONTENT_LENGTH,
+ binary_data.len().to_string(),
+ ),
+ ],
+ binary_data,
+ )
+ .into_response()
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 8b06a28..4bdb424 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -5,6 +5,7 @@ pub mod chat;
pub mod contract_chat;
pub mod contract_daemon;
pub mod contract_discuss;
+pub mod daemon_download;
pub mod contracts;
pub mod directives;
pub mod file_ws;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index 2310ba3..1ad3a8d 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions};
+use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -136,6 +136,9 @@ pub fn make_router(state: SharedState) -> Router {
// Mesh WebSocket endpoints
.route("/mesh/tasks/subscribe", get(mesh_ws::task_subscription_handler))
.route("/mesh/daemons/connect", get(mesh_daemon::daemon_handler))
+ // Daemon binary download endpoints
+ .route("/daemon/download/platforms", get(daemon_download::list_daemon_platforms))
+ .route("/daemon/download/{platform}", get(daemon_download::download_daemon))
// API key management endpoints
.route(
"/auth/api-keys",