feat: deploy zopu single-node production

This commit is contained in:
sai karthik
2026-07-26 11:39:21 +05:30
parent 2a0487aa6e
commit 39b27a229f
15 changed files with 320 additions and 121 deletions

View File

@@ -140,6 +140,11 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
onOpenUnit={(selectedWorkUnitId) =>
navigate(`/chat/${selectedWorkUnitId}`)
}
onProjectSelect={(projectId) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
)
}
onRestoreChecked={handleRestoreChecked}
onSettingsClose={() => workspace.setSettingsOpen(false)}
onSettingsOpen={() => workspace.setSettingsOpen(true)}

View File

@@ -8,11 +8,20 @@
# ---------------------------------------------------------------------------
# 1. Convex (control plane) — REQUIRED
# Convex is already deployed; provide the production deployment URL.
# Public URLs for the self-hosted Convex deployment.
# ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site
SITE_URL=http://localhost:5173
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
VITE_FLUE_URL=http://localhost:3583
# Self-hosted Convex origins used by convex/docker-compose.yml
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET=
# ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
@@ -46,16 +55,15 @@ AGENT_MODEL_MAX_TOKENS=131072
# ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# PORT is the Flue Node HTTP server listen port.
# zopu-agent.service pins the Flue Node server to port 3583.
# ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token
PORT=3583
# ---------------------------------------------------------------------------
# 6. Daemon identity
# ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu Dedicated Server
DAEMON_NAME=Zopu-Dedicated-Server
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000

View File

@@ -1,9 +1,6 @@
# Zopu Single-Node Runtime Deployment
Deployment artifacts for the Zopu execution plane on a single Debian dedicated
server. Convex is already deployed as the control plane; this lane deploys only
the execution plane: the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent
service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
## Architecture
@@ -13,23 +10,17 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
│ ~12 CPU cores · ~40 GB RAM · single-node │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │
│ │ in-process │ │ :3583 │ │ │ │
│ │ engine │ │ │ │ │ │
│ │ + native │ │ │ │ │ │
│ │ sidecar │ │ │ │ │ │
│ │ :6420 │ │ │ │ │ │
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ │ │ :3583 │ │ :3210/:3211 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
└───────────────────
────────▼─────────
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
│ └──────────────────┘ │
─────────────
│ zopu-daemon │ Bun + Effect + RivetKit :6420
──────────────
│ │
│ systemd timers: health-check (60s), docker-cleanup (daily) │
│ cron: disk-monitor (daily 06:00) │
@@ -40,31 +31,15 @@ service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
### Single-node RivetKit topology
The daemon calls `registry.start()` from `rivetkit`, which boots an
**in-process RivetKit engine** (envoy mode) backed by a **native Rust
sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine
listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then
calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process
engine for actor dispatch.
The daemon calls `registry.start()` from `rivetkit`, which boots an **in-process RivetKit engine** (envoy mode) backed by a **native Rust sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process engine for actor dispatch.
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 —
`DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method
calls `#startEnvoy()``runtime.serveRegistry()` for serverful mode (Mode A).
The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the
same `http://localhost:6420`.
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — `DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method calls `#startEnvoy()``runtime.serveRegistry()` for serverful mode (Mode A). The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the same `http://localhost:6420`.
**No separate Rivet Engine process is required.** The engine, actor envoy, and
sidecar all run inside the daemon process. A future multi-node deployment
would externalize the engine, but that is out of scope.
**No separate Rivet Engine process is required.** The engine, actor envoy, and sidecar all run inside the daemon process. A future multi-node deployment would externalize the engine, but that is out of scope.
### Docker / Orb boundary
Docker Engine is installed and the `zopu` service user is in the `docker`
group. The daemon's systemd unit includes `SupplementaryGroups=docker`.
However, **no Docker-backed sandbox code is currently wired**. The Orb
sandbox lane contract has not landed; Docker access is provisioned now so the
boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8)
sandbox, not Docker.
Docker Engine is installed and the `zopu` service user is in the `docker` group. The daemon's systemd unit includes `SupplementaryGroups=docker`. However, **no Docker-backed sandbox code is currently wired**. The Orb sandbox lane contract has not landed; Docker access is provisioned now so the boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8) sandbox, not Docker.
## Files
@@ -76,6 +51,7 @@ deploy/zopu-runtime/
├── systemd/
│ ├── zopu-daemon.service # Daemon systemd unit
│ ├── zopu-agent.service # Agent systemd unit
│ ├── zopu-web.service # React Router web systemd unit
│ ├── zopu-health.service # Health check oneshot
│ ├── zopu-health.timer # Health check every 60s
│ ├── zopu-docker-cleanup.service
@@ -108,7 +84,7 @@ bash bootstrap.sh
nano /opt/zopu/.env
# 5. Start services:
systemctl start zopu-daemon
systemctl start zopu-web zopu-daemon
sleep 3
systemctl start zopu-agent
@@ -122,20 +98,20 @@ systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
## Start / stop / restart
```bash
# Start both services
systemctl start zopu-daemon zopu-agent
# Start all services
systemctl start zopu-web zopu-daemon zopu-agent
# Stop both services
systemctl stop zopu-agent zopu-daemon
# Stop all services
systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-daemon && sleep 3 && systemctl restart zopu-agent
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot
systemctl enable zopu-daemon zopu-agent
systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot
systemctl disable zopu-daemon zopu-agent
systemctl disable zopu-web zopu-daemon zopu-agent
```
## Log inspection
@@ -183,16 +159,14 @@ systemctl list-timers zopu-health.timer
```
The health check probes:
1. `zopu-daemon` systemd unit is active
2. `zopu-agent` systemd unit is active
3. RivetKit engine port (default 6420) accepts TCP connections
4. Flue agent port (default 3583) accepts TCP connections
5. Docker daemon responds to `docker info`
No HTTP health endpoints are assumed. Flue does not expose one by design
(per Flue docs: "Flue does not add a health endpoint"). RivetKit's health
route is internal to the registry runtime and not documented as
publicly addressable on the engine endpoint.
No HTTP health endpoints are assumed. Flue does not expose one by design (per Flue docs: "Flue does not add a health endpoint"). RivetKit's health route is internal to the registry runtime and not documented as publicly addressable on the engine endpoint.
## Update to commit
@@ -208,6 +182,7 @@ publicly addressable on the engine endpoint.
```
The update script:
1. Records current HEAD to `.last-deployed-sha`
2. Fetches, resolves branch-or-commit, checks out
3. `bun install`, builds daemon and agent
@@ -224,9 +199,7 @@ The update script:
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
```
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that
commit, rebuilds, and restarts services. The pre-rollback SHA is saved to
`.pre-rollback-sha` for re-rollback if needed.
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that commit, rebuilds, and restarts services. The pre-rollback SHA is saved to `.pre-rollback-sha` for re-rollback if needed.
## Docker cleanup
@@ -242,11 +215,11 @@ systemctl list-timers zopu-docker-cleanup.timer
```
Cleanup prunes:
- Stopped containers older than 24 hours
- Dangling (untagged) images
- Unused networks
Named volumes and running containers are never removed.
## Disk-space monitoring
@@ -259,69 +232,71 @@ Named volumes and running containers are never removed.
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
```
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`.
Default alert threshold is 80%.
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
## Firewall and private networking
The firewall (`ufw`) is deny-by-default:
- SSH (port 22) is allowed on all interfaces
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
- All other incoming traffic is denied
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not**
exposed on public interfaces. Reachability options:
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0`
so all ports are reachable over the private overlay. Set
`TAILSCALE_AUTHKEY` before running bootstrap to configure automatically.
Other Tailscale-connected machines can reach the agent at
`http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
2. **Custom private interface**: if you have a non-Tailscale private network
(e.g. a VLAN or wireguard interface), add an explicit rule:
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` so all ports are reachable over the private overlay. Set `TAILSCALE_AUTHKEY` before running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent at `http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
```bash
ufw allow in on eth1 # or your private interface name
```
Do NOT assume direct private IP access works by default — the deny-incoming
policy blocks it until an interface-specific rule is added.
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in
`caddy/` if you need TLS termination or a public ingress point.
Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in `caddy/` if you need TLS termination or a public ingress point.
## Environment groups
See [`.env.template`](./.env.template) for the full annotated template. The
eight required groups:
See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
| Group | Variables |
|-------|-----------|
| --- | --- |
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
| Model gateway | `AGENT_MODEL_*` |
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
| Zopu agent | `FLUE_DB_TOKEN`, `PORT` |
| Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
| Docker sandbox | group membership (no env vars) |
| Service auth | `AUTH_SECRET` (if needed) |
## Public single-node routes
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
```bash
docker compose \
--env-file /opt/zopu/.env \
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
exec backend ./generate_admin_key.sh
```
## What is NOT deployed
- **Web app**: the web frontend is not deployed in this lane.
- **Kubernetes**: no container orchestration.
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own
storage; no external PostgreSQL is required.
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
- **Multi-node coordination**: single-node only.
- **Public administration endpoints**: no admin HTTP surface.
- **Secrets in source**: `.env` is never committed; `.env.template` contains
only placeholder values.
- **Production generated-app hosting**: only the execution plane runs here.
- **Docker-backed Orb sandboxes**: Docker is installed and access is
provisioned, but no Orb sandbox code is wired. This is a boundary
prepared for the Orb lane, not a working feature.
- **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
- **Docker-backed Orb sandboxes**: Docker is installed and access is provisioned, but no Orb sandbox code is wired. This is a boundary prepared for the Orb lane, not a working feature.
## Reproducibility
The deployment does not require the developer's MacBook to remain online.
Once bootstrap completes and `.env` is filled in:
The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
1. Services run under systemd with `Restart=always`.
2. Logs persist in journald.

View File

@@ -14,9 +14,9 @@
# TAILSCALE_AUTHKEY — if set, configure Tailscale
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
#
# Installs: Docker Engine, Bun, clones the repo, runs bun install, builds the
# daemon and agent, creates a non-root service user, installs systemd units,
# and configures firewall/Tailscale defaults.
# Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
# web app, daemon, and agent, creates a non-root service user, installs systemd
# units, and configures firewall/Tailscale defaults.
set -euo pipefail
@@ -105,18 +105,31 @@ fi
systemctl enable --now docker
# ---------------------------------------------------------------------------
# 3. Bun
# 3. Node.js 22 (required by the Flue Node target)
# ---------------------------------------------------------------------------
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
log "Installing Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get install -y nodejs
else
log "Node.js already installed: $(node --version)"
fi
# ---------------------------------------------------------------------------
# 4. Bun
# ---------------------------------------------------------------------------
if ! command -v bun &>/dev/null; then
log "Installing Bun..."
curl -fsSL https://bun.sh/install | bash
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
else
log "Bun already installed: $(bun --version)"
fi
# ---------------------------------------------------------------------------
# 4. Service user
# 5. Service user
# ---------------------------------------------------------------------------
if ! id "$SERVICE_USER" &>/dev/null; then
log "Creating service user: $SERVICE_USER"
@@ -129,7 +142,7 @@ if ! id -nG "$SERVICE_USER" | grep -qw docker; then
fi
# ---------------------------------------------------------------------------
# 5. Clone or update repository
# 6. Clone or update repository
# ---------------------------------------------------------------------------
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository exists at $INSTALL_DIR, fetching latest..."
@@ -144,25 +157,28 @@ else
fi
# ---------------------------------------------------------------------------
# 5b. Hand ownership of the checkout to the service user
# 6b. Hand ownership of the checkout to the service user
# ---------------------------------------------------------------------------
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ---------------------------------------------------------------------------
# 6. Install dependencies and build
# 7. Install dependencies and build
# ---------------------------------------------------------------------------
log "Running bun install..."
run_as_service bun install
log "Building daemon binary..."
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# ---------------------------------------------------------------------------
# 7. Environment file
# 8. Environment file
# ---------------------------------------------------------------------------
ENV_FILE="$INSTALL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
@@ -179,17 +195,17 @@ else
fi
# ---------------------------------------------------------------------------
# 8. Persistent log directory
# 9. Persistent log directory
# ---------------------------------------------------------------------------
LOG_DIR="/var/log/zopu"
mkdir -p "$LOG_DIR"
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
# ---------------------------------------------------------------------------
# 9. Install systemd units (substitute placeholders)
# 10. Install systemd units (substitute placeholders)
# ---------------------------------------------------------------------------
log "Installing systemd units..."
for unit in zopu-daemon.service zopu-agent.service \
for unit in zopu-web.service zopu-daemon.service zopu-agent.service \
zopu-health.timer zopu-health.service \
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
SRC="$DEPLOY_DIR/systemd/$unit"
@@ -205,7 +221,7 @@ done
systemctl daemon-reload
# ---------------------------------------------------------------------------
# 10. Firewall (deny-by-default, explicit allow for Tailscale)
# 11. Firewall (deny-by-default, explicit allow for Tailscale)
# ---------------------------------------------------------------------------
log "Configuring firewall..."
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
@@ -227,7 +243,7 @@ else
fi
# ---------------------------------------------------------------------------
# 11. Tailscale (optional)
# 12. Tailscale (optional)
# ---------------------------------------------------------------------------
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
log "Installing and configuring Tailscale..."
@@ -249,7 +265,7 @@ else
fi
# ---------------------------------------------------------------------------
# 12. Disk-space monitoring cron
# 13. Disk-space monitoring cron
# /etc/cron.d format REQUIRES a username field.
# ---------------------------------------------------------------------------
log "Installing disk-space monitor (daily at 06:00)..."
@@ -265,7 +281,7 @@ echo ""
echo "Next steps:"
echo " 1. Edit $ENV_FILE with real values"
echo " 2. Start services:"
echo " systemctl start zopu-daemon zopu-agent"
echo " systemctl start zopu-web zopu-daemon zopu-agent"
echo " 3. Enable health monitoring:"
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
echo " 4. Verify health:"

View File

@@ -17,10 +17,28 @@
# reverse_proxy localhost:3583
# }
#
# --- Public deployment with automatic TLS (only if needed) ---
# --- Public deployment through Cloudflare Tunnel ---
#
# agent.example.invalid {
# reverse_proxy localhost:3583
# Cloudflare terminates TLS and forwards these hosts to Caddy over loopback.
#
# http://zopu.sai-onchain.me {
# bind 127.0.0.1
# reverse_proxy 127.0.0.1:13100
# }
#
# http://zopu-api.sai-onchain.me {
# bind 127.0.0.1
# reverse_proxy 127.0.0.1:3210
# }
#
# http://zopu-site.sai-onchain.me {
# bind 127.0.0.1
# reverse_proxy 127.0.0.1:3211
# }
#
# http://zopu-agent.sai-onchain.me {
# bind 127.0.0.1
# reverse_proxy 127.0.0.1:3583
# }
# Default: do not ship an active Caddyfile. The file is documentation.

View File

@@ -0,0 +1,25 @@
{
admin 127.0.0.1:2019
auto_https off
http_port 8080
}
http://zopu.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:13100
}
http://zopu-api.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3210
}
http://zopu-site.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3211
}
http://zopu-agent.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3583
}

View File

@@ -0,0 +1,13 @@
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
ingress:
- hostname: zopu.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-api.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-site.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-agent.sai-onchain.me
service: http://127.0.0.1:8080
- service: http_status:404

View File

@@ -0,0 +1,32 @@
services:
backend:
image: ghcr.io/get-convex/convex-backend:latest
container_name: zopu-convex
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- "127.0.0.1:3210:3210"
- "127.0.0.1:3211:3211"
volumes:
- zopu-convex-data:/convex/data
environment:
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
DISABLE_BEACON: "true"
DISABLE_METRICS_ENDPOINT: "true"
DOCUMENT_RETENTION_DELAY: "172800"
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
REDACT_LOGS_TO_CLIENT: "true"
RUST_LOG: ${CONVEX_RUST_LOG:-info}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
volumes:
zopu-convex-data:
name: zopu-convex-data

View File

@@ -72,7 +72,10 @@ fi
log "Running bun install..."
run_as_service bun install
log "Building daemon..."
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
@@ -82,6 +85,7 @@ log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5
# Health check
@@ -94,6 +98,7 @@ if [[ -x "$HEALTH_SCRIPT" ]]; then
err "Health check failed after rollback!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
exit 1
fi
fi

View File

@@ -77,17 +77,21 @@ fi
log "Running bun install..."
run_as_service bun install
log "Building daemon binary..."
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# Graceful restart: daemon first (owns RivetKit engine), then agent
# Graceful restart: daemon first (owns RivetKit engine), then agent and web
log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5
# Health check
@@ -100,6 +104,7 @@ if [[ -x "$HEALTH_SCRIPT" ]]; then
err "Health check failed after update!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
exit 1
fi

View File

@@ -10,10 +10,11 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/packages/agents
EnvironmentFile=__INSTALL_DIR__/.env
Environment=PORT=3583
# Flue build output: packages/agents/dist/server.mjs
# Listens on PORT (default 3583 per .env.template).
ExecStart=/usr/local/bin/bun dist/server.mjs
# Flue's Node target requires Node built-ins such as node:sqlite.
# Listens on the service-pinned port 3583.
ExecStart=/usr/bin/node dist/server.mjs
Restart=always
RestartSec=10

View File

@@ -10,11 +10,13 @@ Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOME=/var/lib/zopu
StateDirectory=zopu
# RivetKit in-process engine: envoy mode (serverful).
# registry.start() boots the native Rust sidecar and actor envoy.
# RIVET_ENDPOINT defaults to http://localhost:6420 inside the daemon process.
ExecStart=__INSTALL_DIR__/apps/daemon/dist/code-daemon
# Run the Bun source entrypoint so AgentOS software assets retain their real
# node_modules paths; Bun compiled executables do not embed .aospkg files.
ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
Restart=always
RestartSec=10

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Zopu Web
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/apps/web
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOST=127.0.0.1
Environment=PORT=13100
ExecStart=/usr/local/bin/bun run start
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-web
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,53 @@
import { cn } from "@code/ui/lib/utils";
import { ChevronDown } from "lucide-react";
import type { MobileProjectView } from "./models";
export interface MobileProjectSelectProps {
readonly activeCount: number;
readonly className?: string;
readonly onProjectSelect: (projectId: string) => void;
readonly projects: readonly MobileProjectView[];
readonly selectedProjectId?: string;
}
export const MobileProjectSelect = ({
activeCount,
className,
onProjectSelect,
projects,
selectedProjectId,
}: MobileProjectSelectProps) => (
<div
className={cn(
"mt-0.5 flex min-w-0 items-center gap-1.5 text-[12px] leading-4 text-[#7c7772]",
className
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-[#64ad1f]" />
<label className="relative min-w-0">
<span className="sr-only">Connected project</span>
<select
aria-label="Connected project"
className="max-w-[132px] cursor-pointer appearance-none truncate bg-transparent pr-4 font-medium text-[#5d5955] outline-none focus-visible:ring-2 focus-visible:ring-[#315dc0]"
onChange={(event) => onProjectSelect(event.currentTarget.value)}
value={selectedProjectId ?? ""}
>
{projects.length === 0 ? (
<option value="">No connected project</option>
) : null}
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute top-1/2 right-0 size-3 -translate-y-1/2"
/>
</label>
<span aria-hidden="true">·</span>
<span className="shrink-0 tabular-nums">{activeCount} active</span>
</div>
);

View File

@@ -10,8 +10,10 @@ import {
} from "lucide-react";
import type { FormEvent, MouseEvent } from "react";
import { MobileProjectSelect } from "./mobile-project-select";
import { MobileSettingsPanel } from "./mobile-settings-panel";
import type {
MobileProjectView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
@@ -41,12 +43,18 @@ const dateLabel = new Intl.DateTimeFormat("en", {
interface WorkStackHeaderProps {
readonly activeCount: number;
readonly onOpenSettings?: () => void;
readonly onProjectSelect: (projectId: string) => void;
readonly projects: readonly MobileProjectView[];
readonly selectedProjectId?: string;
readonly userInitials: string;
}
const WorkStackHeader = ({
activeCount,
onOpenSettings,
onProjectSelect,
projects,
selectedProjectId,
userInitials,
}: WorkStackHeaderProps) => (
<header className="flex h-[96px] shrink-0 items-center bg-white px-4">
@@ -57,10 +65,12 @@ const WorkStackHeader = ({
<h1 className="text-[20px] leading-6 font-semibold tracking-[-0.03em] text-[#0b0b0a]">
Zopu
</h1>
<p className="mt-0.5 flex items-center gap-2 text-[13px] leading-4 text-[#7c7772]">
<span className="size-1.5 rounded-full bg-[#64ad1f]" />
{activeCount} active work units
</p>
<MobileProjectSelect
activeCount={activeCount}
onProjectSelect={onProjectSelect}
projects={projects}
selectedProjectId={selectedProjectId}
/>
</div>
<button
aria-label="Open settings"
@@ -291,6 +301,7 @@ export interface MobileWorkStackScreenProps {
readonly onComposerChange: (value: string) => void;
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onOpenUnit: (workUnitId: string) => void;
readonly onProjectSelect: (projectId: string) => void;
readonly onRestoreChecked: () => void;
readonly onSettingsClose: () => void;
readonly onSettingsOpen: () => void;
@@ -314,6 +325,7 @@ export const MobileWorkStackScreen = ({
onComposerChange,
onComposerSubmit,
onOpenUnit,
onProjectSelect,
onRestoreChecked,
onSettingsClose,
onSettingsOpen,
@@ -339,6 +351,9 @@ export const MobileWorkStackScreen = ({
<WorkStackHeader
activeCount={data.activeCount}
onOpenSettings={onSettingsOpen}
onProjectSelect={onProjectSelect}
projects={data.projects}
selectedProjectId={data.selectedProjectId}
userInitials={userInitials}
/>
<main className="relative min-h-0 flex-1 overflow-hidden bg-[#f7f8f5] px-5 pt-[26px]">