Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
894fe05c85 fix(sanitize): protect markdown code blocks from bluemonday HTML encoding
bluemonday.Sanitize() was applied to raw markdown, which corrupted code
blocks by encoding > to &gt;, < to &lt;, and stripping tag-like syntax
(e.g. Array<string> became Array). Now fenced and inline code blocks are
extracted before sanitization and restored after, preserving code content
while still stripping XSS from non-code regions.

Closes #704
2026-04-11 21:22:46 +08:00
31 changed files with 1332 additions and 1978 deletions

View File

@@ -11,7 +11,6 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:

View File

@@ -30,16 +30,6 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
## Quick Start
```bash
# One-command setup: configure, authenticate, and start the daemon
multica setup
# For self-hosted (local) deployments:
multica setup --local
```
Or step by step:
```bash
# 1. Authenticate (opens browser for login)
multica login
@@ -172,31 +162,28 @@ Agent-specific overrides:
### Self-Hosted Server
When connecting to a self-hosted Multica instance, the easiest approach is:
When connecting to a self-hosted Multica instance, you **must** point the CLI to your server before logging in. The CLI defaults to the hosted Multica service — skipping this step means the daemon will authenticate against the wrong server.
```bash
# One command — auto-detects local server, configures, authenticates, starts daemon
multica setup --local
```
# Local Docker Compose (default ports):
export MULTICA_APP_URL=http://localhost:3000
export MULTICA_SERVER_URL=ws://localhost:8080/ws
Or configure manually:
```bash
# Configure for local Docker Compose (default ports)
multica config local
# Or set URLs individually:
# multica config set app_url http://localhost:3000
# multica config set server_url http://localhost:8080
# For production with TLS:
# multica config set app_url https://app.example.com
# multica config set server_url https://api.example.com
# Production with TLS:
# export MULTICA_APP_URL=https://app.example.com
# export MULTICA_SERVER_URL=wss://api.example.com/ws
multica login
multica daemon start
```
Or set them persistently:
```bash
multica config set app_url http://localhost:3000
multica config set server_url ws://localhost:8080/ws
```
### Profiles
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
@@ -324,21 +311,6 @@ multica issue run-messages <task-id> --since 42 --output json
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Setup
```bash
# One-command setup: configure, authenticate, and start the daemon
multica setup
# For local self-hosted deployments (auto-detects or forces local mode)
multica setup --local
# Custom ports
multica setup --local --port 9090 --frontend-port 4000
```
`multica setup` detects whether a local Multica server is running, configures the CLI, opens your browser for authentication, and starts the daemon — all in one step.
## Configuration
### View Config
@@ -349,19 +321,10 @@ multica config show
Shows config file path, server URL, app URL, and default workspace.
### Configure for Local Self-Hosted
```bash
multica config local # Uses default ports (8080/3000)
multica config local --port 9090 --frontend-port 4000 # Custom ports
```
Sets `server_url` and `app_url` for a local Docker Compose deployment in one command.
### Set Values
```bash
multica config set server_url https://api.example.com
multica config set server_url wss://api.example.com/ws
multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```

View File

@@ -36,9 +36,7 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

View File

@@ -1,4 +1,4 @@
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,53 +36,6 @@ define REQUIRE_ENV
fi
endef
# ---------- Self-hosting (Docker Compose) ----------
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup --local"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
# Stop all Docker Compose self-host services
selfhost-stop:
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations
@@ -190,11 +143,10 @@ multica:
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:

102
README.md
View File

@@ -47,36 +47,58 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
---
## Quick Install
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
After installation:
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the local agent runtime
multica daemon stop # Stop the daemon when done
```
> **Self-hosting?** Add `--local` to deploy a full Multica server on your machine:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---
## Getting Started
### Multica Cloud
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
### Self-Host with Docker
**Prerequisites:** Docker and Docker Compose.
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# Edit .env — change JWT_SECRET at minimum
docker compose -f docker-compose.selfhost.yml up -d
```
This builds and starts PostgreSQL, the backend (with auto-migration), and the frontend. Open http://localhost:3000 when ready.
See the [Self-Hosting Guide](SELF_HOSTING.md) for full configuration, reverse proxy setup, and CLI/daemon instructions.
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
**Option A — paste this to your coding agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**Option B — install manually:**
```bash
# Install
brew tap multica-ai/tap
brew install multica
# Authenticate and start
multica login
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
## Quickstart
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
### 1. Log in and start the daemon
```bash
@@ -84,7 +106,7 @@ multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
### 2. Verify your runtime
@@ -100,27 +122,7 @@ Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
| Command | Description |
|---------|-------------|
| `multica login` | Authenticate (opens browser) |
| `multica daemon start` | Start the local agent runtime |
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup (configure + login + start daemon) |
| `multica setup --local` | Same, but for self-hosted deployments |
| `multica config local` | Configure CLI for a local self-hosted server |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference.
---
That's it! Your agent is now part of the team. 🎉
## Architecture

View File

@@ -47,33 +47,52 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
---
## 快速开始
## 快速安装
### Multica 云服务
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
### Docker 自部署
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# 编辑 .env — 至少修改 JWT_SECRET
docker compose up -d # 启动 PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
make start # 启动应用
```
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew没有则直接下载二进制
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)
安装完成后:
## CLI
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
**方式 A — 将以下指令粘贴给你的 coding agentClaude Code、Codex、OpenClaw、OpenCode 等):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**方式 B — 手动安装:**
```bash
multica login # 认证(打开浏览器)
multica daemon start # 启动本地 Agent 运行时
multica daemon stop # 停止 daemon
# 安装
brew tap multica-ai/tap
brew install multica
# 认证并启动
multica login
multica daemon start
```
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> ```
>
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
---
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
## 快速上手

View File

@@ -1,8 +1,10 @@
# Self-Hosting Guide
Deploy Multica on your own infrastructure in minutes.
This guide walks you through deploying Multica on your own infrastructure.
## Architecture
## Architecture Overview
Multica has three components:
| Component | Description | Technology |
|-----------|-------------|------------|
@@ -10,152 +12,12 @@ Deploy Multica on your own infrastructure in minutes.
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Quick Install (Recommended)
One command to set up everything — server, CLI, and configuration:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
Once complete, open http://localhost:3000, log in with any email + verification code **`888888`**, then:
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the agent daemon
```
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
---
## Step-by-Step Setup (Alternative)
If you prefer to run each step manually:
### Step 1 — Start the Server
## Quick Start (Docker Compose)
**Prerequisites:** Docker and Docker Compose.
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
```
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
### Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
Each team member who wants to run AI agents locally needs to:
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
```
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
### b) One-command setup
```bash
multica setup --local
```
This automatically:
1. Configures the CLI to connect to `localhost` (ports 8080/3000)
2. Opens your browser for authentication
3. Discovers your workspaces
4. Starts the daemon in the background
To verify the daemon is running:
```bash
multica daemon status
```
> **Alternative:** If you prefer manual steps, see [Manual CLI Configuration](#manual-cli-configuration) below.
### Step 4 — Verify & Start Using
1. Open your workspace in the web app at http://localhost:3000
2. Navigate to **Settings → Runtimes** — you should see your machine listed
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
## Stopping Services
If you installed via the install script:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop
```
If you cloned the repo manually:
```bash
# Stop the Docker Compose services (backend, frontend, database)
make selfhost-stop
# Stop the local daemon
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
```
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Rebuilding After Updates
```bash
git pull
make selfhost
```
Migrations run automatically on backend startup.
---
## Manual Docker Compose Setup
If you prefer running Docker Compose steps manually instead of `make selfhost`:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
@@ -174,34 +36,297 @@ Then start everything:
docker compose -f docker-compose.selfhost.yml up -d
```
## Manual CLI Configuration
This builds and starts PostgreSQL, the backend (with auto-migration), and the frontend:
If you prefer configuring the CLI step by step instead of `multica setup`:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
The backend automatically runs database migrations on startup — no manual migration step needed.
To run AI agents, you also need to set up the daemon on your local machine. See [Setting Up the Agent Daemon](#setting-up-the-agent-daemon) below.
### Rebuilding After Updates
```bash
# Point CLI to your local server
multica config local
# Or set URLs manually:
# multica config set app_url http://localhost:3000
# multica config set server_url http://localhost:8080
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
For production deployments with TLS:
Migrations run automatically on each backend startup.
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using Docker Compose (Recommended)
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
### Using Your Own PostgreSQL
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
### Running Migrations Manually
The Docker Compose setup runs migrations automatically. If you need to run them manually:
```bash
multica config set app_url https://app.example.com
multica config set server_url https://api.example.com
multica login
multica daemon start
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Advanced Configuration
## Manual Setup (Without Docker Compose)
For environment variables, manual setup (without Docker), reverse proxy configuration, database setup, and more, see the [Advanced Configuration Guide](SELF_HOSTING_ADVANCED.md).
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Setting Up the Agent Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them as runtimes with the server, and executes tasks when agents are assigned work.
Each team member who wants to run AI agents locally needs to:
1. **Install the CLI**
```bash
brew tap multica-ai/tap
brew install multica-cli
```
2. **Install an AI agent CLI** — at least one of:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
3. **Point the CLI to your server**
The CLI defaults to the hosted Multica service. For self-hosted setups, you **must** set the server URLs before logging in:
```bash
# Local Docker Compose deployment (default ports):
export MULTICA_APP_URL=http://localhost:3000
export MULTICA_SERVER_URL=ws://localhost:8080/ws
# Production deployment with TLS:
# export MULTICA_APP_URL=https://app.example.com
# export MULTICA_SERVER_URL=wss://api.example.com/ws
```
> **Note:** Use `http://` and `ws://` for local deployments without TLS. Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy.
You can also set these persistently so you don't need to export them each time:
```bash
multica config set app_url http://localhost:3000
multica config set server_url ws://localhost:8080/ws
```
4. **Authenticate and start**
```bash
# Login (opens browser to your frontend)
multica login
# Start the daemon
multica daemon start
```
The login flow opens your browser, authenticates you via the frontend, and stores a personal access token locally. The daemon then uses this token to register with the backend.
To verify the daemon is running:
```bash
multica daemon status
```
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

@@ -1,224 +0,0 @@
# Self-Hosting — Advanced Configuration
This document covers advanced configuration for self-hosted Multica deployments. For the quick start guide, see [SELF_HOSTING.md](SELF_HOSTING.md).
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using Docker Compose (Recommended)
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
### Using Your Own PostgreSQL
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
### Running Migrations Manually
The Docker Compose setup runs migrations automatically. If you need to run them manually:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

@@ -1,74 +0,0 @@
# Self-Hosting Setup (for AI Agents)
This document is designed for AI agents to execute. Follow these steps exactly to deploy a local Multica instance and connect to it.
## Prerequisites
- Docker and Docker Compose installed
- Homebrew installed (for CLI)
- At least one AI agent CLI on PATH: `claude` or `codex`
## One-Command Install
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This installs everything: clones the repo, starts Docker services, and installs the CLI.
Wait for the output `✓ Multica is installed and running!` before proceeding.
**Expected result:**
- Frontend at http://localhost:3000
- Backend at http://localhost:8080
- `multica` CLI installed and configured for localhost
## Alternative: Manual Setup
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
brew install multica-ai/tap/multica
multica setup --local
```
The `multica setup --local` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use verification code `888888` with any email
3. Discover workspaces automatically
4. Start the daemon in the background
## Verification
```bash
multica daemon status
```
Should show `running` with detected agents.
## Stopping
```bash
# Stop the daemon
multica daemon stop
# Stop all Docker services
cd multica
make selfhost-stop
```
## Custom Ports
If the default ports (8080/3000) are in use:
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
2. Run `make selfhost`
3. Run `multica setup --local --port <PORT> --frontend-port <FRONTEND_PORT>`
## Troubleshooting
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
- **Daemon issues:** `multica daemon logs`
- **Health check:** `curl http://localhost:8080/health`

View File

@@ -13,142 +13,50 @@ Multica has three components:
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Each user who wants to run AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Prerequisites
- Docker and Docker Compose
- Docker and Docker Compose (recommended), or:
- Go 1.26+ (to build from source)
- Node.js 20+ and pnpm 10.28+ (to build the frontend)
- PostgreSQL 17 with the pgvector extension
## Quick Install
One command to set up everything:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
1. Open http://localhost:3000 — log in with any email + code **`888888`**
2. Run `multica login` and `multica daemon start`
<Callout>
For a step-by-step setup, see below.
</Callout>
## Step-by-Step Setup
### Step 1 — Start the Server
## Quick Start (Docker Compose)
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
cp .env.example .env
```
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
<Callout>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
</Callout>
### Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
### a) Install the CLI and an AI agent
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
```bash
brew install multica-ai/tap/multica
# Start PostgreSQL
docker compose up -d
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server
```
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
### b) One-command setup
For the frontend:
```bash
multica setup --local
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
This automatically:
1. Configures the CLI to connect to `localhost`
2. Opens your browser for authentication
3. Discovers your workspaces
4. Starts the daemon in the background
Verify the daemon is running:
```bash
multica daemon status
```
<Callout>
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
</Callout>
### Step 4 — Verify & Start Using
1. Open your workspace at http://localhost:3000
2. Navigate to **Settings → Runtimes** — you should see your machine listed
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Stopping Services
```bash
# Stop Docker Compose services
make selfhost-stop
# Stop the local daemon
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
```
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
<Callout>
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Rebuilding After Updates
```bash
git pull
make selfhost
```
Migrations run automatically on backend startup.
---
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
@@ -243,36 +151,6 @@ Migrations must be run before starting the server:
cd server && go run ./cmd/migrate up
```
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
@@ -361,6 +239,39 @@ GET /health
Use this for load balancer health checks or monitoring.
## Setting Up the Agent Daemon
Each team member who wants to run AI agents locally needs to:
1. **Install the CLI**
```bash
brew tap multica-ai/tap
brew install multica-cli
```
2. **Install an AI agent CLI** — at least one of:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
3. **Authenticate and start**
```bash
# Point CLI to your server
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
```
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
The daemon auto-detects installed agent CLIs and registers itself with the server.
## Upgrading
1. Pull the latest code or image

View File

@@ -61,7 +61,6 @@ services:
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
depends_on:
- backend
ports:

View File

@@ -119,40 +119,20 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
let cancelled = false;
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
if (cancelled || tasks.length === 0) return;
// Show cards immediately with empty timeline
setTaskStates((prev) => {
const next = new Map(prev);
for (const task of tasks) {
if (!next.has(task.id)) {
next.set(task.id, { task, items: [] });
}
}
return next;
});
// Load messages per task in the background
for (const task of tasks) {
api.listTaskMessages(task.id).then((msgs) => {
if (cancelled) return;
const newStates = new Map<string, TaskState>();
const loadPromises = tasks.map(async (task) => {
try {
const msgs = await api.listTaskMessages(task.id);
const timeline = buildTimeline(msgs);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
setTaskStates((prev) => {
const next = new Map(prev);
const existing = next.get(task.id);
if (existing) {
// Merge: keep any WS-delivered items not in the loaded batch
const loadedSeqs = new Set(timeline.map((i) => i.seq));
const wsOnly = existing.items.filter((i) => !loadedSeqs.has(i.seq));
const merged = [...timeline, ...wsOnly].sort((a, b) => a.seq - b.seq);
next.set(task.id, { task: existing.task, items: merged });
} else {
next.set(task.id, { task, items: timeline });
}
return next;
});
}).catch(console.error);
}
newStates.set(task.id, { task, items: timeline });
} catch {
newStates.set(task.id, { task, items: [] });
}
});
Promise.all(loadPromises).then(() => {
if (!cancelled) setTaskStates(newStates);
});
}).catch(console.error);
return () => { cancelled = true; };
@@ -374,7 +354,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
)}
>
<div className="overflow-hidden">
{items.length > 0 ? (
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
@@ -400,12 +380,6 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
</button>
)}
</div>
) : (
<div className="border-t border-info/10 px-3 py-3">
<p className="text-xs text-muted-foreground">
Live log is not available for this agent provider. Results will appear when the task completes.
</p>
</div>
)}
</div>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useRef, useState } from "react";
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
@@ -35,7 +35,7 @@ import { FileUploadButton } from "@multica/ui/components/common/file-upload-butt
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import type { TimelineEntry } from "@multica/core/types";
// ---------------------------------------------------------------------------
// Types
@@ -91,44 +91,6 @@ function DeleteCommentDialog({
);
}
// ---------------------------------------------------------------------------
// Standalone attachment list — renders attachments not already in the markdown
// ---------------------------------------------------------------------------
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content
const standalone = content
? attachments.filter((a) => !content.includes(a.url))
: attachments;
if (!standalone.length) return null;
return (
<div className={cn("flex flex-col gap-1", className)}>
{standalone.map((a) => (
<div
key={a.id}
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{a.filename}</p>
</div>
{a.download_url && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={() => window.open(a.download_url, "_blank", "noopener,noreferrer")}
>
<Download className="size-3.5" />
</button>
)}
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Single comment row (used for both parent and replies within the same Card)
// ---------------------------------------------------------------------------
@@ -294,7 +256,6 @@ function CommentRow({
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
{!isTemp && (
<ReactionBar
reactions={reactions}
@@ -511,7 +472,6 @@ function CommentCard({
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
{!isTemp && (
<ReactionBar
reactions={reactions}

View File

@@ -173,7 +173,7 @@ function ActorSubContent({
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const query = search.trim().toLowerCase();
const query = search.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
@@ -306,7 +306,7 @@ function ProjectSubContent({
const [search, setSearch] = useState("");
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const query = search.trim().toLowerCase();
const query = search.toLowerCase();
const filtered = projects.filter((p) =>
p.title.toLowerCase().includes(query),
);

View File

@@ -67,7 +67,7 @@ export function AssigneePicker({
const getFreq = (type: string, id: string) => freqMap.get(`${type}:${id}`) ?? 0;
const query = filter.trim().toLowerCase();
const query = filter.toLowerCase();
const filteredMembers = members
.filter((m) => m.name.toLowerCase().includes(query))
.sort((a, b) => getFreq("member", b.user_id) - getFreq("member", a.user_id));

View File

@@ -34,7 +34,6 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
updated_at: c.updated_at,
comment_type: c.type,
reactions: c.reactions ?? [],
attachments: c.attachments ?? [],
};
}

View File

@@ -1,10 +1,10 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus, Check } from "lucide-react";
import { useState, useRef } from "react";
import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { projectListOptions } from "@multica/core/projects/queries";
import { useCreateProject, useUpdateProject } from "@multica/core/projects/mutations";
import { useCreateProject } from "@multica/core/projects/mutations";
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspaceStore } from "@multica/core/workspace";
@@ -36,7 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { TitleEditor } from "../../editor";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
import type { Project, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { PriorityIcon } from "../../issues/components/priority-icon";
function formatRelativeDate(date: string): string {
@@ -50,83 +50,32 @@ function formatRelativeDate(date: string): string {
}
function ProjectRow({ project }: { project: Project }) {
const wsId = useWorkspaceId();
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
const updateProject = useUpdateProject();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
const leadQuery = leadFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
const handleUpdate = useCallback(
(data: UpdateProjectRequest) => {
updateProject.mutate({ id: project.id, ...data });
},
[project.id, updateProject],
);
return (
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
{/* Icon + Name (navigates to detail) */}
<AppLink
href={`/projects/${project.id}`}
className="flex min-w-0 flex-1 items-center gap-2"
>
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
</AppLink>
<AppLink
href={`/projects/${project.id}`}
className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40"
>
{/* Icon + Name */}
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
{/* Priority — dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<button type="button" className="flex w-24 items-center justify-center gap-1 shrink-0 rounded px-1 py-0.5 hover:bg-accent/60 transition-colors cursor-pointer">
<PriorityIcon priority={project.priority} />
<span className={cn("text-xs", priorityCfg.color)}>{priorityCfg.label}</span>
</button>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdate({ priority: p as ProjectPriority })}>
<PriorityIcon priority={p} />
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
{p === project.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<span className="flex w-24 items-center justify-center gap-1 shrink-0">
<PriorityIcon priority={project.priority} />
<span className={cn("text-xs", priorityCfg.color)}>{priorityCfg.label}</span>
</span>
{/* Status — dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<button type="button" className={cn(
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium shrink-0 w-28 justify-center cursor-pointer hover:opacity-80 transition-opacity",
statusCfg.badgeBg, statusCfg.badgeText,
)}>
{statusCfg.label}
</button>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleUpdate({ status: s as ProjectStatus })}>
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Status */}
<span className={cn(
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium shrink-0 w-28 justify-center",
statusCfg.badgeBg, statusCfg.badgeText,
)}>
{statusCfg.label}
</span>
{/* Progress (read-only) */}
{/* Progress */}
<span className="flex w-24 items-center justify-center gap-1.5 shrink-0">
{project.issue_count > 0 ? (
<>
@@ -145,85 +94,20 @@ function ProjectRow({ project }: { project: Project }) {
)}
</span>
{/* Lead — popover */}
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger
render={
<button type="button" className="flex w-10 items-center justify-center shrink-0 rounded-full hover:ring-2 hover:ring-accent transition-all cursor-pointer">
{project.lead_type && project.lead_id ? (
<Tooltip>
<TooltipTrigger render={<span><ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={22} /></span>} />
<TooltipContent side="bottom">{getActorName(project.lead_type, project.lead_id)}</TooltipContent>
</Tooltip>
) : (
<span className="h-[22px] w-[22px] rounded-full border border-dashed border-muted-foreground/30" />
)}
</button>
}
/>
<PopoverContent align="start" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={leadFilter}
onChange={(e) => setLeadFilter(e.target.value)}
placeholder="Assign lead..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => { handleUpdate({ lead_type: null, lead_id: null }); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">No lead</span>
</button>
{filteredMembers.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
{filteredMembers.map((m) => (
<button
type="button"
key={m.user_id}
onClick={() => { handleUpdate({ lead_type: "member", lead_id: m.user_id }); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
<span>{m.name}</span>
</button>
))}
</>
)}
{filteredAgents.length > 0 && (
<>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
{filteredAgents.map((a) => (
<button
type="button"
key={a.id}
onClick={() => { handleUpdate({ lead_type: "agent", lead_id: a.id }); setLeadOpen(false); }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<span>{a.name}</span>
</button>
))}
</>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Lead */}
<span className="flex w-10 items-center justify-center shrink-0">
{project.lead_type && project.lead_id ? (
<ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={22} />
) : (
<span className="h-[22px] w-[22px] rounded-full border border-dashed border-muted-foreground/30" />
)}
</span>
{/* Created */}
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatRelativeDate(project.created_at)}
</span>
</div>
</AppLink>
);
}

View File

@@ -1,433 +0,0 @@
#!/usr/bin/env bash
# Multica installer — one command to get started.
#
# Install CLI (default): connects to multica.ai
# curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
#
# Self-host: starts a local Multica server + installs CLI + configures
# curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REPO_URL="https://github.com/multica-ai/multica.git"
REPO_WEB_URL="https://github.com/multica-ai/multica" # without .git, for GitHub web APIs
INSTALL_DIR="${MULTICA_INSTALL_DIR:-$HOME/.multica/server}"
BREW_PACKAGE="multica-ai/tap/multica"
# Colors (disabled when not a terminal)
if [ -t 1 ] || [ -t 2 ]; then
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
RESET='\033[0m'
else
BOLD='' GREEN='' YELLOW='' RED='' CYAN='' RESET=''
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info() { printf "${BOLD}${CYAN}==> %s${RESET}\n" "$*"; }
ok() { printf "${BOLD}${GREEN}✓ %s${RESET}\n" "$*"; }
warn() { printf "${BOLD}${YELLOW}⚠ %s${RESET}\n" "$*" >&2; }
fail() { printf "${BOLD}${RED}✗ %s${RESET}\n" "$*" >&2; exit 1; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
detect_os() {
case "$(uname -s)" in
Darwin) OS="darwin" ;;
Linux) OS="linux" ;;
*) fail "Unsupported operating system: $(uname -s). Multica supports macOS and Linux." ;;
esac
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
arm64) ARCH="arm64" ;;
*) fail "Unsupported architecture: $ARCH" ;;
esac
}
# ---------------------------------------------------------------------------
# CLI Installation
# ---------------------------------------------------------------------------
install_cli_brew() {
info "Installing Multica CLI via Homebrew..."
if ! brew tap multica-ai/tap 2>/dev/null; then
fail "Failed to add Homebrew tap. Check your network connection."
fi
# brew install exits non-zero if already installed on older Homebrew versions
if ! brew install multica 2>/dev/null; then
if brew list multica >/dev/null 2>&1; then
ok "Multica CLI already installed via Homebrew"
else
fail "Failed to install multica via Homebrew."
fi
else
ok "Multica CLI installed via Homebrew"
fi
}
install_cli_binary() {
info "Installing Multica CLI from GitHub Releases..."
# Get latest release tag
local latest
latest=$(curl -sI "$REPO_WEB_URL/releases/latest" 2>/dev/null | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n' || true)
if [ -z "$latest" ]; then
fail "Could not determine latest release. Check your network connection."
fi
local url="https://github.com/multica-ai/multica/releases/download/${latest}/multica_${OS}_${ARCH}.tar.gz"
local tmp_dir
tmp_dir=$(mktemp -d)
info "Downloading $url ..."
if ! curl -fsSL "$url" -o "$tmp_dir/multica.tar.gz"; then
rm -rf "$tmp_dir"
fail "Failed to download CLI binary."
fi
tar -xzf "$tmp_dir/multica.tar.gz" -C "$tmp_dir" multica
# Try /usr/local/bin first, fall back to ~/.local/bin
local bin_dir="/usr/local/bin"
if [ -w "$bin_dir" ]; then
mv "$tmp_dir/multica" "$bin_dir/multica"
elif command_exists sudo; then
sudo mv "$tmp_dir/multica" "$bin_dir/multica"
else
bin_dir="$HOME/.local/bin"
mkdir -p "$bin_dir"
mv "$tmp_dir/multica" "$bin_dir/multica"
chmod +x "$bin_dir/multica"
# Add to PATH if not already there
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$bin_dir$"; then
export PATH="$bin_dir:$PATH"
add_to_path "$bin_dir"
fi
fi
rm -rf "$tmp_dir"
ok "Multica CLI installed to $bin_dir/multica"
}
add_to_path() {
local dir="$1"
local line="export PATH=\"$dir:\$PATH\""
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
if [ -f "$rc" ] && ! grep -qF "$dir" "$rc"; then
printf '\n# Added by Multica installer\n%s\n' "$line" >> "$rc"
fi
done
}
get_latest_version() {
# grep exits 1 when no match; use `|| true` to avoid triggering pipefail
curl -sI "$REPO_WEB_URL/releases/latest" 2>/dev/null | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n' || true
}
upgrade_cli_brew() {
info "Upgrading Multica CLI via Homebrew..."
brew update 2>/dev/null || true
if brew upgrade multica 2>/dev/null; then
ok "Multica CLI upgraded via Homebrew"
else
# brew upgrade exits non-zero if already up to date
ok "Multica CLI is already the latest version"
fi
}
install_cli() {
if command_exists multica; then
local current_ver
# `multica version` outputs "multica v0.1.13 (commit: abc1234)" — extract just the version
current_ver=$(multica version 2>/dev/null | awk '{print $2}' || echo "unknown")
local latest_ver
latest_ver=$(get_latest_version)
# Normalize: strip leading 'v' for comparison
local current_cmp="${current_ver#v}"
local latest_cmp="${latest_ver#v}"
if [ -z "$latest_ver" ] || [ "$current_cmp" = "$latest_cmp" ]; then
ok "Multica CLI is up to date ($current_ver)"
return 0
fi
info "Multica CLI $current_ver installed, latest is $latest_ver — upgrading..."
if command_exists brew && brew list multica >/dev/null 2>&1; then
upgrade_cli_brew
else
install_cli_binary
fi
local new_ver
new_ver=$(multica version 2>/dev/null | awk '{print $2}' || echo "unknown")
ok "Multica CLI upgraded ($current_ver$new_ver)"
return 0
fi
if command_exists brew; then
install_cli_brew
else
install_cli_binary
fi
# Verify
if ! command_exists multica; then
fail "CLI installed but 'multica' not found on PATH. You may need to restart your shell."
fi
}
# ---------------------------------------------------------------------------
# Docker check
# ---------------------------------------------------------------------------
check_docker() {
if ! command_exists docker; then
printf "\n"
fail "Docker is not installed. Multica self-hosting requires Docker and Docker Compose.
Install Docker:
macOS: https://docs.docker.com/desktop/install/mac-install/
Linux: https://docs.docker.com/engine/install/
After installing Docker, re-run this script with --local."
fi
if ! docker info >/dev/null 2>&1; then
fail "Docker is installed but not running. Please start Docker and re-run this script."
fi
ok "Docker is available"
}
# ---------------------------------------------------------------------------
# Server setup (self-host / --local)
# ---------------------------------------------------------------------------
setup_server() {
info "Setting up Multica server..."
if [ -d "$INSTALL_DIR/.git" ]; then
info "Updating existing installation at $INSTALL_DIR..."
cd "$INSTALL_DIR"
git fetch origin main --depth 1 2>/dev/null || true
git reset --hard origin/main 2>/dev/null || true
else
info "Cloning Multica repository..."
if ! command_exists git; then
fail "Git is not installed. Please install git and re-run."
fi
# Remove leftover directory from a previously interrupted clone
if [ -d "$INSTALL_DIR" ]; then
warn "Removing incomplete installation at $INSTALL_DIR..."
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
ok "Repository ready at $INSTALL_DIR"
# Generate .env if needed
if [ ! -f .env ]; then
info "Creating .env with random JWT_SECRET..."
cp .env.example .env
local jwt
jwt=$(openssl rand -hex 32)
if [ "$(uname -s)" = "Darwin" ]; then
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$jwt/" .env
else
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$jwt/" .env
fi
ok "Generated .env with random JWT_SECRET"
else
ok "Using existing .env"
fi
# Start Docker Compose
info "Starting Multica services (this may take a few minutes on first run)..."
docker compose -f docker-compose.selfhost.yml up -d --build
# Wait for health check
info "Waiting for backend to be ready..."
local ready=false
for i in $(seq 1 45); do
if curl -sf http://localhost:8080/health >/dev/null 2>&1; then
ready=true
break
fi
sleep 2
done
if [ "$ready" = true ]; then
ok "Multica server is running"
else
warn "Server is still starting. You can check logs with:"
echo " cd $INSTALL_DIR && docker compose -f docker-compose.selfhost.yml logs"
echo ""
fi
}
# ---------------------------------------------------------------------------
# Configure CLI for local server
# ---------------------------------------------------------------------------
configure_local() {
info "Configuring CLI for local server..."
multica config local 2>/dev/null || {
# Fallback if config local doesn't exist in installed version
multica config set app_url http://localhost:3000 2>/dev/null || true
multica config set server_url http://localhost:8080 2>/dev/null || true
}
ok "CLI configured for localhost (backend :8080, frontend :3000)"
}
# ---------------------------------------------------------------------------
# Configure CLI for Multica Cloud
# ---------------------------------------------------------------------------
configure_cloud() {
info "Configuring CLI for Multica Cloud..."
multica config set server_url https://api.multica.ai 2>/dev/null || true
multica config set app_url https://multica.ai 2>/dev/null || true
ok "CLI configured for multica.ai"
}
# ---------------------------------------------------------------------------
# Main: Default mode (cloud — install CLI to connect to multica.ai)
# ---------------------------------------------------------------------------
run_default() {
printf "\n"
printf "${BOLD} Multica — Installer${RESET}\n"
printf " Installing the CLI to connect to ${CYAN}multica.ai${RESET}\n"
printf "\n"
detect_os
install_cli
configure_cloud
printf "\n"
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
printf "${BOLD}${GREEN} ✓ Multica CLI is installed!${RESET}\n"
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
printf "\n"
printf " ${BOLD}Next steps:${RESET}\n"
printf "\n"
printf " ${CYAN}multica login${RESET} # Authenticate with multica.ai\n"
printf " ${CYAN}multica daemon start${RESET} # Start the agent daemon\n"
printf "\n"
printf " Or do it all in one command:\n"
printf "\n"
printf " ${CYAN}multica setup${RESET}\n"
printf "\n"
printf " ${BOLD}Self-hosting?${RESET} Re-run with --local:\n"
printf " curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local\n"
printf "\n"
}
# ---------------------------------------------------------------------------
# Main: Local mode (self-host — full server + CLI)
# ---------------------------------------------------------------------------
run_local() {
printf "\n"
printf "${BOLD} Multica — Self-Host Installer${RESET}\n"
printf " Setting up a local Multica server + CLI\n"
printf "\n"
detect_os
check_docker
setup_server
install_cli
configure_local
printf "\n"
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
printf "${BOLD}${GREEN} ✓ Multica is installed and running!${RESET}\n"
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
printf "\n"
printf " ${BOLD}Frontend:${RESET} http://localhost:3000\n"
printf " ${BOLD}Backend:${RESET} http://localhost:8080\n"
printf " ${BOLD}Server at:${RESET} %s\n" "$INSTALL_DIR"
printf "\n"
printf " ${BOLD}Next steps:${RESET}\n"
printf " 1. Open ${CYAN}http://localhost:3000${RESET} in your browser\n"
printf " 2. Log in with any email + verification code: ${BOLD}888888${RESET}\n"
printf " 3. Then run:\n"
printf "\n"
printf " ${CYAN}multica login${RESET} # Authenticate (opens browser)\n"
printf " ${CYAN}multica daemon start${RESET} # Start the agent daemon\n"
printf "\n"
printf " ${BOLD}To stop all services:${RESET}\n"
printf " curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop\n"
printf "\n"
printf " Or manually:\n"
printf " cd %s && docker compose -f docker-compose.selfhost.yml down\n" "$INSTALL_DIR"
printf " multica daemon stop\n"
printf "\n"
}
# ---------------------------------------------------------------------------
# Stop: shut down a self-hosted installation
# ---------------------------------------------------------------------------
run_stop() {
printf "\n"
info "Stopping Multica services..."
if [ -d "$INSTALL_DIR" ]; then
cd "$INSTALL_DIR"
if [ -f docker-compose.selfhost.yml ]; then
docker compose -f docker-compose.selfhost.yml down
ok "Docker services stopped"
else
warn "No docker-compose.selfhost.yml found at $INSTALL_DIR"
fi
else
warn "No Multica installation found at $INSTALL_DIR"
fi
if command_exists multica; then
multica daemon stop 2>/dev/null && ok "Daemon stopped" || true
fi
printf "\n"
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
main() {
local mode="default"
while [ $# -gt 0 ]; do
case "$1" in
--local) mode="local" ;;
--stop) mode="stop" ;;
--help|-h)
echo "Usage: install.sh [--local | --stop]"
echo ""
echo " (default) Install the Multica CLI to connect to multica.ai"
echo " --local Self-host: set up a local Multica server + CLI"
echo " --stop Stop a self-hosted installation"
exit 0
;;
*) warn "Unknown option: $1" ;;
esac
shift
done
case "$mode" in
default) run_default ;;
local) run_local ;;
stop) run_stop ;;
esac
}
main "$@"

View File

@@ -29,20 +29,9 @@ var configSetCmd = &cobra.Command{
RunE: runConfigSet,
}
var configLocalCmd = &cobra.Command{
Use: "local",
Short: "Configure CLI for a local Docker Compose deployment",
Long: "Sets server_url and app_url to localhost defaults for a local self-hosted deployment.",
RunE: runConfigLocal,
}
func init() {
configLocalCmd.Flags().Int("port", 8080, "Backend server port")
configLocalCmd.Flags().Int("frontend-port", 3000, "Frontend port")
configCmd.AddCommand(configShowCmd)
configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configLocalCmd)
}
func runConfigShow(cmd *cobra.Command, _ []string) error {
@@ -91,30 +80,6 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
return nil
}
func runConfigLocal(cmd *cobra.Command, _ []string) error {
port, _ := cmd.Flags().GetInt("port")
frontendPort, _ := cmd.Flags().GetInt("frontend-port")
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return err
}
cfg.AppURL = fmt.Sprintf("http://localhost:%d", frontendPort)
cfg.ServerURL = fmt.Sprintf("http://localhost:%d", port)
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Configured for local deployment:\n")
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
fmt.Fprintf(os.Stderr, "\nNext: run 'multica login' to authenticate.\n")
return nil
}
func valueOrDefault(v, fallback string) string {
if v == "" {
return fallback

View File

@@ -174,20 +174,12 @@ func runDaemonBackground(cmd *cobra.Command) error {
fmt.Fprintf(os.Stderr, "Warning: could not write PID file: %v\n", err)
}
// Poll health endpoint until the daemon is ready or timeout.
deadline := time.Now().Add(15 * time.Second)
started := false
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
hctx, hcancel := context.WithTimeout(context.Background(), 2*time.Second)
health = checkDaemonHealthOnPort(hctx, healthPort)
hcancel()
if health["status"] == "running" {
started = true
break
}
}
if !started {
// Wait briefly and verify daemon started via health endpoint.
time.Sleep(2 * time.Second)
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel2()
health = checkDaemonHealthOnPort(ctx2, healthPort)
if health["status"] != "running" {
fmt.Fprintf(os.Stderr, "Daemon may not have started successfully. Check logs:\n %s\n", logPath)
return nil
}

View File

@@ -1,100 +0,0 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var setupCmd = &cobra.Command{
Use: "setup",
Short: "One-command setup: configure, authenticate, and start the daemon",
Long: `Detects a local Multica server, configures the CLI, authenticates via browser,
and starts the agent daemon — all in one step.
Use --local to skip auto-detection and force local mode.`,
RunE: runSetup,
}
func init() {
setupCmd.Flags().Bool("local", false, "Force local mode (skip server auto-detection)")
setupCmd.Flags().Int("port", 8080, "Backend server port (for local mode)")
setupCmd.Flags().Int("frontend-port", 3000, "Frontend port (for local mode)")
}
func runSetup(cmd *cobra.Command, args []string) error {
forceLocal, _ := cmd.Flags().GetBool("local")
port, _ := cmd.Flags().GetInt("port")
frontendPort, _ := cmd.Flags().GetInt("frontend-port")
profile := resolveProfile(cmd)
isLocal := forceLocal
if !forceLocal {
// Auto-detect a local server on the default port.
isLocal = probeLocalServer(port)
}
if isLocal {
fmt.Fprintln(os.Stderr, "Detected local Multica server.")
cfg, _ := cli.LoadCLIConfigForProfile(profile)
cfg.AppURL = fmt.Sprintf("http://localhost:%d", frontendPort)
cfg.ServerURL = fmt.Sprintf("http://localhost:%d", port)
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Fprintf(os.Stderr, " app_url: %s\n", cfg.AppURL)
fmt.Fprintf(os.Stderr, " server_url: %s\n", cfg.ServerURL)
} else if !forceLocal {
fmt.Fprintln(os.Stderr, "No local server detected — using Multica Cloud (https://multica.ai).")
cfg, _ := cli.LoadCLIConfigForProfile(profile)
cfg.AppURL = "https://multica.ai"
cfg.ServerURL = "https://api.multica.ai"
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save config: %w", err)
}
}
// Authenticate.
fmt.Fprintln(os.Stderr, "")
if err := runLogin(cmd, args); err != nil {
return err
}
// Start daemon in background.
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
return nil
}
// probeLocalServer checks whether a Multica backend is running on localhost.
func probeLocalServer(port int) bool {
url := fmt.Sprintf("http://localhost:%d/health", port)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false
}
resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

View File

@@ -17,7 +17,7 @@ var updateCmd = &cobra.Command{
}
func runUpdate(_ *cobra.Command, _ []string) error {
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s, built: %s)\n", version, commit, date)
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s)\n", version, commit)
// Check latest version from GitHub.
latest, err := cli.FetchLatestRelease()

View File

@@ -1,42 +1,15 @@
package main
import (
"encoding/json"
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
)
func init() {
versionCmd.Flags().String("output", "text", "Output format: text or json")
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
RunE: runVersion,
}
func runVersion(cmd *cobra.Command, _ []string) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
info := map[string]string{
"version": version,
"commit": commit,
"date": date,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(info)
}
fmt.Printf("multica %s (commit: %s, built: %s)\n", version, commit, date)
fmt.Printf("go: %s, os/arch: %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
return nil
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("multica %s (commit: %s)\n", version, commit)
},
}

View File

@@ -10,7 +10,6 @@ import (
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
var rootCmd = &cobra.Command{
@@ -41,7 +40,6 @@ func init() {
// Additional commands
authCmd.GroupID = groupAdditional
loginCmd.GroupID = groupAdditional
setupCmd.GroupID = groupAdditional
attachmentCmd.GroupID = groupAdditional
configCmd.GroupID = groupAdditional
updateCmd.GroupID = groupAdditional
@@ -57,7 +55,6 @@ func init() {
rootCmd.AddCommand(runtimeCmd)
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(setupCmd)
rootCmd.AddCommand(attachmentCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(updateCmd)

View File

@@ -1,7 +1,9 @@
package sanitize
import (
"fmt"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
)
@@ -11,11 +13,6 @@ var httpURL = regexp.MustCompile(`^https?://`)
// policy is a shared bluemonday policy that allows safe Markdown HTML while
// stripping dangerous elements (script, iframe, object, embed, style, on*).
//
// Note: bluemonday operates on raw text, so HTML inside Markdown code blocks
// (e.g. ```<script>```) will also be stripped. This is an acceptable trade-off
// for defense-in-depth — the primary sanitization happens in the frontend via
// rehype-sanitize which understands the Markdown AST.
var policy *bluemonday.Policy
func init() {
@@ -28,8 +25,44 @@ func init() {
policy.AllowAttrs("class").OnElements("code", "div", "span", "pre")
}
// fencedCodeBacktick matches ```-fenced code blocks (with optional language tag).
var fencedCodeBacktick = regexp.MustCompile("(?ms)^```[^\n]*\n.*?^```\\s*$")
// fencedCodeTilde matches ~~~-fenced code blocks (with optional language tag).
var fencedCodeTilde = regexp.MustCompile("(?ms)^~~~[^\n]*\n.*?^~~~\\s*$")
// inlineCodeDouble matches double-backtick inline code (e.g. ``code``).
var inlineCodeDouble = regexp.MustCompile("``[^`]+``")
// inlineCodeSingle matches single-backtick inline code (e.g. `code`).
var inlineCodeSingle = regexp.MustCompile("`[^`\n]+`")
// HTML sanitizes user-provided HTML/Markdown content, stripping dangerous
// tags (script, iframe, object, embed, etc.) and event-handler attributes.
//
// Code blocks (fenced and inline) are protected from bluemonday to prevent
// it from encoding HTML entities or stripping tag-like syntax in code.
func HTML(input string) string {
return policy.Sanitize(input)
var placeholders []string
replace := func(match string) string {
idx := len(placeholders)
placeholders = append(placeholders, match)
return fmt.Sprintf("\x00CODE_%d\x00", idx)
}
// Protect fenced code blocks first (higher priority), then inline code.
s := fencedCodeBacktick.ReplaceAllStringFunc(input, replace)
s = fencedCodeTilde.ReplaceAllStringFunc(s, replace)
s = inlineCodeDouble.ReplaceAllStringFunc(s, replace)
s = inlineCodeSingle.ReplaceAllStringFunc(s, replace)
s = policy.Sanitize(s)
// Restore code blocks.
for i, original := range placeholders {
s = strings.Replace(s, fmt.Sprintf("\x00CODE_%d\x00", i), original, 1)
}
return s
}

View File

@@ -80,13 +80,69 @@ func TestHTML(t *testing.T) {
input: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
want: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
},
// Code block protection tests (issue #704)
{
name: "fenced code block preserves angle brackets",
input: "```go\nfunc foo() <-chan int {\n\treturn make(chan int)\n}\n```",
want: "```go\nfunc foo() <-chan int {\n\treturn make(chan int)\n}\n```",
},
{
name: "fenced code block preserves generics",
input: "```typescript\nconst x: Array<string> = []\n```",
want: "```typescript\nconst x: Array<string> = []\n```",
},
{
name: "fenced code block preserves gt operator",
input: "```python\nif x > 0:\n print(x)\n```",
want: "```python\nif x > 0:\n print(x)\n```",
},
{
name: "fenced code block preserves HTML tags in code",
input: "```html\n<script>alert(1)</script>\n<div>hello</div>\n```",
want: "```html\n<script>alert(1)</script>\n<div>hello</div>\n```",
},
{
name: "inline code preserves angle brackets",
input: "Use `Array<string>` for typed arrays",
want: "Use `Array<string>` for typed arrays",
},
{
name: "inline code preserves gt operator",
input: "Check `x > 0` before proceeding",
want: "Check `x > 0` before proceeding",
},
{
name: "inline code preserves ampersand",
input: "Use `a & b` for bitwise AND",
want: "Use `a & b` for bitwise AND",
},
{
name: "double backtick inline code preserved",
input: "Use ``Map<string, List<int>>`` for nested generics",
want: "Use ``Map<string, List<int>>`` for nested generics",
},
{
name: "mixed code and XSS - code protected, XSS stripped",
input: "Use `x > 0` and <script>alert(1)</script> done",
want: "Use `x > 0` and done",
},
{
name: "tilde fenced code block preserved",
input: "~~~rust\nfn main() -> Result<(), Error> {}\n~~~",
want: "~~~rust\nfn main() -> Result<(), Error> {}\n~~~",
},
{
name: "multiple code blocks preserved",
input: "```go\na > b\n```\n\nSome text\n\n```ts\nx < y\n```",
want: "```go\na > b\n```\n\nSome text\n\n```ts\nx < y\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HTML(tt.input)
if got != tt.want {
t.Errorf("HTML(%q) = %q, want %q", tt.input, got, tt.want)
t.Errorf("HTML() =\n %q\nwant\n %q", got, tt.want)
}
})
}

View File

@@ -268,23 +268,15 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
slog.Info("task completed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID))
// Post agent output as a comment, but only for assignment-triggered issue tasks
// where the agent did NOT already post a comment during execution.
// Post agent output as a comment, but only for issue tasks with assignment triggers.
// Comment-triggered tasks: the agent replies via CLI with --parent, so
// posting here would create a duplicate.
// Chat tasks: no comment posting needed.
if task.IssueID.Valid && !task.TriggerCommentID.Valid {
agentCommented, _ := s.Queries.HasAgentCommentedSince(ctx, db.HasAgentCommentedSinceParams{
IssueID: task.IssueID,
AuthorID: task.AgentID,
Since: task.StartedAt,
})
if !agentCommented {
var payload protocol.TaskCompletedPayload
if err := json.Unmarshal(result, &payload); err == nil {
if payload.Output != "" {
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(payload.Output), "comment", task.TriggerCommentID)
}
var payload protocol.TaskCompletedPayload
if err := json.Unmarshal(result, &payload); err == nil {
if payload.Output != "" {
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(payload.Output), "comment", task.TriggerCommentID)
}
}
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
@@ -10,7 +11,7 @@ import (
"time"
)
// openclawBackend implements Backend by spawning `openclaw agent --message <prompt>
// openclawBackend implements Backend by spawning `openclaw agent -p <prompt>
// --output-format stream-json --yes` and reading streaming NDJSON events from
// stdout — similar to the opencode backend.
type openclawBackend struct {
@@ -32,15 +33,20 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
sessionID := opts.ResumeSessionID
if sessionID == "" {
sessionID = fmt.Sprintf("multica-%d", time.Now().UnixNano())
args := []string{"agent", "--output-format", "stream-json", "--yes"}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
args := []string{"agent", "--local", "--json", "--session-id", sessionID}
if opts.Timeout > 0 {
args = append(args, "--timeout", fmt.Sprintf("%d", int(opts.Timeout.Seconds())))
if opts.SystemPrompt != "" {
args = append(args, "--system-prompt", opts.SystemPrompt)
}
args = append(args, "--message", prompt)
if opts.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
}
if opts.ResumeSessionID != "" {
args = append(args, "--session", opts.ResumeSessionID)
}
args = append(args, "-p", prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
if opts.Cwd != "" {
@@ -48,13 +54,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
}
cmd.Env = buildEnv(b.cfg.Env)
// openclaw writes its --json output to stderr, not stdout.
stderr, err := cmd.StderrPipe()
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("openclaw stderr pipe: %w", err)
return nil, fmt.Errorf("openclaw stdout pipe: %w", err)
}
cmd.Stdout = newLogWriter(b.cfg.Logger, "[openclaw:stdout] ")
cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
@@ -72,7 +77,7 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
defer close(resCh)
startTime := time.Now()
scanResult := b.processOutput(stderr, msgCh)
scanResult := b.processEvents(stdout, msgCh)
// Wait for process exit.
exitErr := cmd.Wait()
@@ -127,84 +132,86 @@ type openclawEventResult struct {
usage TokenUsage
}
// processOutput reads the JSON output from openclaw --json stderr and returns
// the parsed result. OpenClaw writes its JSON result to stderr, which may also
// contain non-JSON log lines. We find the result JSON by trying each '{' until
// one successfully unmarshals as an openclawResult with payloads.
func (b *openclawBackend) processOutput(r io.Reader, ch chan<- Message) openclawEventResult {
data, err := io.ReadAll(r)
if err != nil {
return openclawEventResult{status: "failed", errMsg: fmt.Sprintf("read stderr: %v", err)}
}
raw := string(data)
// Try each '{' position until we find valid openclawResult JSON.
// Earlier '{' chars may appear in log/error lines (e.g. raw_params={...}).
var result openclawResult
jsonStart := -1
for i := 0; i < len(raw); i++ {
if raw[i] != '{' {
continue
}
if err := json.Unmarshal([]byte(raw[i:]), &result); err == nil && result.Payloads != nil {
jsonStart = i
break
}
}
// Log non-JSON lines before the result
if jsonStart > 0 {
for _, line := range strings.Split(raw[:jsonStart], "\n") {
line = strings.TrimSpace(line)
if line != "" {
b.cfg.Logger.Debug("[openclaw:stderr] " + line)
}
}
}
if jsonStart < 0 {
trimmed := strings.TrimSpace(raw)
if trimmed != "" {
b.cfg.Logger.Debug("[openclaw:stderr] " + trimmed)
return openclawEventResult{status: "completed", output: trimmed}
}
return openclawEventResult{status: "failed", errMsg: "openclaw returned no parseable output"}
}
// Extract text from payloads
// processEvents reads NDJSON lines from r, dispatches events to ch, and returns
// the accumulated result.
func (b *openclawBackend) processEvents(r io.Reader, ch chan<- Message) openclawEventResult {
var output strings.Builder
for _, p := range result.Payloads {
if p.Text != "" {
if output.Len() > 0 {
output.WriteString("\n")
}
output.WriteString(p.Text)
}
}
// Extract session ID and usage from meta
var sessionID string
var usage TokenUsage
if result.Meta.AgentMeta != nil {
if sid, ok := result.Meta.AgentMeta["sessionId"].(string); ok {
sessionID = sid
finalStatus := "completed"
var finalError string
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if u, ok := result.Meta.AgentMeta["usage"].(map[string]any); ok {
usage.InputTokens = openclawInt64(u, "input")
usage.OutputTokens = openclawInt64(u, "output")
usage.CacheReadTokens = openclawInt64(u, "cacheRead")
usage.CacheWriteTokens = openclawInt64(u, "cacheWrite")
var event openclawEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
if event.SessionID != "" {
sessionID = event.SessionID
}
switch event.Type {
case "text":
b.handleOCTextEvent(event, ch, &output)
case "thinking":
b.handleOCThinkingEvent(event, ch)
case "tool_call":
b.handleOCToolCallEvent(event, ch)
case "error":
// NOTE: error events unconditionally set finalStatus to "failed" and
// it stays sticky — subsequent text or result events won't revert it.
// This is intentional: once an error fires, the session is considered
// failed regardless of later events.
b.handleOCErrorEvent(event, ch, &finalStatus, &finalError)
case "step_start":
trySend(ch, Message{Type: MessageStatus, Status: "running"})
case "step_end":
// Accumulate token usage from step_end events if present.
if event.Data != nil {
usage.InputTokens += openclawInt64(event.Data, "inputTokens")
usage.OutputTokens += openclawInt64(event.Data, "outputTokens")
usage.CacheReadTokens += openclawInt64(event.Data, "cacheReadTokens")
usage.CacheWriteTokens += openclawInt64(event.Data, "cacheWriteTokens")
}
case "result":
// The result event only updates status on explicit failure. A
// "completed" result is a no-op because finalStatus defaults to
// "completed". Any unrecognized status (e.g. "partial") is also
// treated as success — update this if OpenClaw adds new statuses.
if event.Data != nil {
if s, ok := event.Data["status"].(string); ok && s != "" {
if s == "error" || s == "failed" {
finalStatus = "failed"
if msg, ok := event.Data["error"].(string); ok {
finalError = msg
}
}
}
}
}
}
// Send final text as a message
if output.Len() > 0 {
trySend(ch, Message{Type: MessageText, Content: output.String()})
// Check for scanner errors (e.g. broken pipe, read errors).
if scanErr := scanner.Err(); scanErr != nil {
b.cfg.Logger.Warn("openclaw stdout scanner error", "error", scanErr)
if finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
}
}
return openclawEventResult{
status: "completed",
status: finalStatus,
errMsg: finalError,
output: output.String(),
sessionID: sessionID,
usage: usage,
@@ -228,19 +235,118 @@ func openclawInt64(data map[string]any, key string) int64 {
}
}
// ── JSON types for `openclaw agent --json` output ──
// openclawResult represents the JSON output from `openclaw agent --json`.
type openclawResult struct {
Payloads []openclawPayload `json:"payloads"`
Meta openclawMeta `json:"meta"`
func (b *openclawBackend) handleOCTextEvent(event openclawEvent, ch chan<- Message, output *strings.Builder) {
text := openclawExtractText(event.Data)
if text != "" {
output.WriteString(text)
trySend(ch, Message{Type: MessageText, Content: text})
}
}
type openclawPayload struct {
Text string `json:"text"`
func (b *openclawBackend) handleOCThinkingEvent(event openclawEvent, ch chan<- Message) {
text := openclawExtractText(event.Data)
if text != "" {
trySend(ch, Message{Type: MessageThinking, Content: text})
}
}
type openclawMeta struct {
DurationMs int64 `json:"durationMs"`
AgentMeta map[string]any `json:"agentMeta"`
// handleOCToolCallEvent processes "tool_call" events from OpenClaw. A single
// tool_call event may contain both the call and result when the tool has
// completed (status == "completed").
func (b *openclawBackend) handleOCToolCallEvent(event openclawEvent, ch chan<- Message) {
if event.Data == nil {
return
}
name, _ := event.Data["name"].(string)
callID, _ := event.Data["callId"].(string)
// Extract input.
var input map[string]any
if raw, ok := event.Data["input"]; ok {
if m, ok := raw.(map[string]any); ok {
input = m
}
}
// Emit the tool-use message.
trySend(ch, Message{
Type: MessageToolUse,
Tool: name,
CallID: callID,
Input: input,
})
// If the tool has completed, also emit a tool-result message.
status, _ := event.Data["status"].(string)
if status == "completed" {
outputStr := extractToolOutput(event.Data["output"])
trySend(ch, Message{
Type: MessageToolResult,
Tool: name,
CallID: callID,
Output: outputStr,
})
}
}
func (b *openclawBackend) handleOCErrorEvent(event openclawEvent, ch chan<- Message, finalStatus, finalError *string) {
errMsg := ""
if event.Data != nil {
if msg, ok := event.Data["message"].(string); ok {
errMsg = msg
}
if errMsg == "" {
if code, ok := event.Data["code"].(string); ok {
errMsg = code
}
}
}
if errMsg == "" {
errMsg = "unknown openclaw error"
}
b.cfg.Logger.Warn("openclaw error event", "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
*finalStatus = "failed"
*finalError = errMsg
}
// openclawExtractText extracts text content from an openclaw event data map.
// Supports both flat {"text": "..."} and nested {"content": {"text": "..."}} layouts.
func openclawExtractText(data map[string]any) string {
if data == nil {
return ""
}
// Try "text" field directly.
if text, ok := data["text"].(string); ok {
return text
}
// Try nested "content.text".
if content, ok := data["content"].(map[string]any); ok {
if text, ok := content["text"].(string); ok {
return text
}
}
return ""
}
// ── JSON types for `openclaw agent --output-format stream-json` stdout events ──
// openclawEvent represents a single NDJSON line from OpenClaw's stream-json output.
//
// Event types:
//
// "step_start" — agent step begins
// "text" — text output from agent
// "thinking" — model reasoning/thinking
// "tool_call" — tool invocation with call and result
// "error" — error from openclaw
// "step_end" — agent step completes
// "result" — final result with status
type openclawEvent struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
Data map[string]any `json:"data,omitempty"`
}

View File

@@ -1,7 +1,6 @@
package agent
import (
"encoding/json"
"log/slog"
"strings"
"testing"
@@ -18,47 +17,426 @@ func TestNewReturnsOpenclawBackend(t *testing.T) {
}
}
// ── processOutput tests ──
// ── Text event tests ──
func TestOpenclawProcessOutputHappyPath(t *testing.T) {
func TestOpenclawHandleTextEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{
Type: "text",
SessionID: "ses_abc",
Data: map[string]any{"text": "Hello from openclaw"},
}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "Hello from openclaw" {
t.Errorf("output: got %q, want %q", output.String(), "Hello from openclaw")
}
msg := <-ch
if msg.Type != MessageText {
t.Errorf("type: got %v, want MessageText", msg.Type)
}
if msg.Content != "Hello from openclaw" {
t.Errorf("content: got %q, want %q", msg.Content, "Hello from openclaw")
}
}
func TestOpenclawHandleTextEventEmpty(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{
Type: "text",
Data: map[string]any{"text": ""},
}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
func TestOpenclawHandleTextEventNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
var output strings.Builder
event := openclawEvent{Type: "text"}
b.handleOCTextEvent(event, ch, &output)
if output.String() != "" {
t.Errorf("expected empty output, got %q", output.String())
}
if len(ch) != 0 {
t.Errorf("expected no messages, got %d", len(ch))
}
}
// ── Thinking event tests ──
func TestOpenclawHandleThinkingEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "thinking",
Data: map[string]any{"text": "Let me think about this..."},
}
b.handleOCThinkingEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageThinking {
t.Errorf("type: got %v, want MessageThinking", msg.Type)
}
if msg.Content != "Let me think about this..." {
t.Errorf("content: got %q", msg.Content)
}
}
// ── Tool call event tests ──
func TestOpenclawHandleToolCallCompleted(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "tool_call",
Data: map[string]any{
"name": "bash",
"callId": "call_123",
"input": map[string]any{"command": "pwd"},
"status": "completed",
"output": "/tmp/project\n",
},
}
b.handleOCToolCallEvent(event, ch)
// Should emit both tool-use and tool-result.
if len(ch) != 2 {
t.Fatalf("expected 2 messages, got %d", len(ch))
}
// First: tool-use
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
if msg.Tool != "bash" {
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
}
if msg.CallID != "call_123" {
t.Errorf("callID: got %q, want %q", msg.CallID, "call_123")
}
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
t.Errorf("input.command: got %v", msg.Input["command"])
}
// Second: tool-result
msg = <-ch
if msg.Type != MessageToolResult {
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
}
if msg.Output != "/tmp/project\n" {
t.Errorf("output: got %q", msg.Output)
}
}
func TestOpenclawHandleToolCallPending(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "tool_call",
Data: map[string]any{
"name": "read",
"callId": "call_456",
"input": map[string]any{"filePath": "/tmp/test.go"},
"status": "pending",
},
}
b.handleOCToolCallEvent(event, ch)
if len(ch) != 1 {
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
}
msg := <-ch
if msg.Type != MessageToolUse {
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
}
}
func TestOpenclawHandleToolCallNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{Type: "tool_call"}
b.handleOCToolCallEvent(event, ch)
if len(ch) != 0 {
t.Errorf("expected no messages for nil data, got %d", len(ch))
}
}
// ── Error event tests ──
func TestOpenclawHandleErrorEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{
Type: "error",
SessionID: "ses_abc",
Data: map[string]any{"message": "Model not found: bad/model"},
}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if status != "failed" {
t.Errorf("status: got %q, want %q", status, "failed")
}
if errMsg != "Model not found: bad/model" {
t.Errorf("error: got %q", errMsg)
}
msg := <-ch
if msg.Type != MessageError {
t.Errorf("type: got %v, want MessageError", msg.Type)
}
}
func TestOpenclawHandleErrorEventCodeOnly(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{
Type: "error",
Data: map[string]any{"code": "RateLimitError"},
}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if errMsg != "RateLimitError" {
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
}
}
func TestOpenclawHandleErrorEventNilData(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 10)
status := "completed"
errMsg := ""
event := openclawEvent{Type: "error"}
b.handleOCErrorEvent(event, ch, &status, &errMsg)
if errMsg != "unknown openclaw error" {
t.Errorf("error: got %q, want %q", errMsg, "unknown openclaw error")
}
}
// ── Integration-level tests: processEvents ──
func TestOpenclawProcessEventsHappyPath(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Hello from openclaw"}},
Meta: openclawMeta{
DurationMs: 1234,
AgentMeta: map[string]any{
"sessionId": "ses_abc",
"usage": map[string]any{
"input": float64(100),
"output": float64(50),
"cacheRead": float64(10),
"cacheWrite": float64(5),
},
},
},
}
data, _ := json.Marshal(result)
// Simulate a successful run: step_start → text → tool_call → text → step_end
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_happy"}`,
`{"type":"text","sessionId":"ses_happy","data":{"text":"Analyzing..."}}`,
`{"type":"tool_call","sessionId":"ses_happy","data":{"name":"bash","callId":"call_1","input":{"command":"ls"},"status":"completed","output":"file.go\n"}}`,
`{"type":"text","sessionId":"ses_happy","data":{"text":" Done."}}`,
`{"type":"step_end","sessionId":"ses_happy"}`,
}, "\n")
res := b.processOutput(strings.NewReader(string(data)), ch)
result := b.processEvents(strings.NewReader(lines), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if res.output != "Hello from openclaw" {
t.Errorf("output: got %q, want %q", res.output, "Hello from openclaw")
if result.sessionID != "ses_happy" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
}
if res.sessionID != "ses_abc" {
t.Errorf("sessionID: got %q, want %q", res.sessionID, "ses_abc")
if result.output != "Analyzing... Done." {
t.Errorf("output: got %q, want %q", result.output, "Analyzing... Done.")
}
if res.usage.InputTokens != 100 {
t.Errorf("input tokens: got %d, want 100", res.usage.InputTokens)
if result.errMsg != "" {
t.Errorf("errMsg: got %q, want empty", result.errMsg)
}
if res.usage.OutputTokens != 50 {
t.Errorf("output tokens: got %d, want 50", res.usage.OutputTokens)
// Drain and verify messages.
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
// Expected: status(running), text, tool-use, tool-result, text = 5 messages
if len(msgs) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
}
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
}
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing..." {
t.Errorf("msg[1]: got %+v", msgs[1])
}
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
}
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file.go\n" {
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
}
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
t.Errorf("msg[4]: got %+v", msgs[4])
}
}
func TestOpenclawProcessEventsErrorCausesFailedStatus(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_err"}`,
`{"type":"error","sessionId":"ses_err","data":{"message":"Model not found: bad/model"}}`,
`{"type":"step_end","sessionId":"ses_err"}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "Model not found: bad/model" {
t.Errorf("errMsg: got %q", result.errMsg)
}
if result.sessionID != "ses_err" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
}
close(ch)
var errorMsgs int
for m := range ch {
if m.Type == MessageError {
errorMsgs++
}
}
if errorMsgs != 1 {
t.Errorf("expected 1 error message, got %d", errorMsgs)
}
}
func TestOpenclawProcessEventsSessionIDExtracted(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"step_start","sessionId":"ses_first"}`,
`{"type":"text","sessionId":"ses_updated","data":{"text":"hi"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.sessionID != "ses_updated" {
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
}
close(ch)
}
func TestOpenclawProcessEventsScannerError(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := b.processEvents(&ioErrReader{
data: `{"type":"text","sessionId":"ses_scan","data":{"text":"before error"}}` + "\n",
}, ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if !strings.Contains(result.errMsg, "stdout read error") {
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
}
if result.output != "before error" {
t.Errorf("output: got %q, want %q", result.output, "before error")
}
close(ch)
}
func TestOpenclawProcessEventsEmptyLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
"",
" ",
"not json at all",
`{"type":"text","sessionId":"ses_ok","data":{"text":"valid"}}`,
"",
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "valid" {
t.Errorf("output: got %q, want %q", result.output, "valid")
}
if result.sessionID != "ses_ok" {
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
}
close(ch)
@@ -67,190 +445,130 @@ func TestOpenclawProcessOutputHappyPath(t *testing.T) {
msgs = append(msgs, m)
}
if len(msgs) != 1 || msgs[0].Type != MessageText {
t.Errorf("expected 1 text message, got %d", len(msgs))
}
if msgs[0].Content != "Hello from openclaw" {
t.Errorf("message content: got %q", msgs[0].Content)
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
}
}
func TestOpenclawProcessOutputMultiplePayloads(t *testing.T) {
func TestOpenclawProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{
{Text: "First"},
{Text: "Second"},
lines := strings.Join([]string{
`{"type":"error","sessionId":"ses_x","data":{"message":"RateLimitError"}}`,
`{"type":"text","sessionId":"ses_x","data":{"text":"recovered?"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
}
if result.errMsg != "RateLimitError" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
}
close(ch)
}
func TestOpenclawProcessEventsResultEvent(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"text","sessionId":"ses_r","data":{"text":"Done"}}`,
`{"type":"result","sessionId":"ses_r","data":{"status":"completed"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "completed" {
t.Errorf("status: got %q, want %q", result.status, "completed")
}
if result.output != "Done" {
t.Errorf("output: got %q, want %q", result.output, "Done")
}
close(ch)
}
func TestOpenclawProcessEventsResultErrorStatus(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
lines := strings.Join([]string{
`{"type":"result","sessionId":"ses_rf","data":{"status":"error","error":"out of tokens"}}`,
}, "\n")
result := b.processEvents(strings.NewReader(lines), ch)
if result.status != "failed" {
t.Errorf("status: got %q, want %q", result.status, "failed")
}
if result.errMsg != "out of tokens" {
t.Errorf("errMsg: got %q, want %q", result.errMsg, "out of tokens")
}
close(ch)
}
// ── openclawExtractText tests ──
func TestExtractEventTextDirect(t *testing.T) {
t.Parallel()
data := map[string]any{"text": "hello"}
if got := openclawExtractText(data); got != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestExtractEventTextNested(t *testing.T) {
t.Parallel()
data := map[string]any{
"content": map[string]any{"text": "nested hello"},
}
if got := openclawExtractText(data); got != "nested hello" {
t.Errorf("got %q, want %q", got, "nested hello")
}
}
func TestExtractEventTextNil(t *testing.T) {
t.Parallel()
if got := openclawExtractText(nil); got != "" {
t.Errorf("got %q, want empty", got)
}
}
// ── Thinking event with nested content ──
func TestOpenclawHandleThinkingEventNestedContent(t *testing.T) {
t.Parallel()
b := &openclawBackend{}
ch := make(chan Message, 10)
event := openclawEvent{
Type: "thinking",
Data: map[string]any{
"content": map[string]any{"text": "Nested thinking"},
},
}
data, _ := json.Marshal(result)
res := b.processOutput(strings.NewReader(string(data)), ch)
b.handleOCThinkingEvent(event, ch)
if res.output != "First\nSecond" {
t.Errorf("output: got %q, want %q", res.output, "First\nSecond")
if len(ch) != 1 {
t.Fatalf("expected 1 message, got %d", len(ch))
}
close(ch)
}
func TestOpenclawProcessOutputEmptyPayloads(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{Payloads: []openclawPayload{}}
data, _ := json.Marshal(result)
res := b.processOutput(strings.NewReader(string(data)), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
msg := <-ch
if msg.Type != MessageThinking {
t.Errorf("type: got %v, want MessageThinking", msg.Type)
}
if res.output != "" {
t.Errorf("output: got %q, want empty", res.output)
}
close(ch)
var msgs []Message
for m := range ch {
msgs = append(msgs, m)
}
if len(msgs) != 0 {
t.Errorf("expected 0 messages, got %d", len(msgs))
}
}
func TestOpenclawProcessOutputWithLeadingLogLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Done"}},
}
data, _ := json.Marshal(result)
input := "some log line\nanother log\n" + string(data)
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Done" {
t.Errorf("output: got %q, want %q", res.output, "Done")
}
close(ch)
}
func TestOpenclawProcessOutputNoJSON(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(strings.NewReader("not json at all"), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "not json at all" {
t.Errorf("output: got %q", res.output)
}
close(ch)
}
func TestOpenclawProcessOutputEmptyInput(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(strings.NewReader(""), ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if res.errMsg != "openclaw returned no parseable output" {
t.Errorf("errMsg: got %q", res.errMsg)
}
close(ch)
}
func TestOpenclawProcessOutputReadError(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
res := b.processOutput(&ioErrReader{data: ""}, ch)
if res.status != "failed" {
t.Errorf("status: got %q, want %q", res.status, "failed")
}
if !strings.Contains(res.errMsg, "read stderr") {
t.Errorf("errMsg: got %q, want it to contain 'read stderr'", res.errMsg)
}
close(ch)
}
func TestOpenclawProcessOutputWithBracesInLogLines(t *testing.T) {
t.Parallel()
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
ch := make(chan Message, 256)
result := openclawResult{
Payloads: []openclawPayload{{Text: "Final answer"}},
Meta: openclawMeta{DurationMs: 500},
}
data, _ := json.Marshal(result)
// Simulate error line containing braces before the real JSON (the exact bug scenario)
input := `[tools] exec failed: complex interpreter invocation detected. raw_params={"command":"echo hello"}` + "\n" + string(data)
res := b.processOutput(strings.NewReader(input), ch)
if res.status != "completed" {
t.Errorf("status: got %q, want %q", res.status, "completed")
}
if res.output != "Final answer" {
t.Errorf("output: got %q, want %q", res.output, "Final answer")
}
close(ch)
}
// ── openclawInt64 tests ──
func TestOpenclawInt64Float(t *testing.T) {
t.Parallel()
data := map[string]any{"count": float64(42)}
if got := openclawInt64(data, "count"); got != 42 {
t.Errorf("got %d, want 42", got)
}
}
func TestOpenclawInt64Missing(t *testing.T) {
t.Parallel()
data := map[string]any{}
if got := openclawInt64(data, "count"); got != 0 {
t.Errorf("got %d, want 0", got)
}
}
func TestOpenclawInt64Nil(t *testing.T) {
t.Parallel()
data := map[string]any{"count": "not a number"}
if got := openclawInt64(data, "count"); got != 0 {
t.Errorf("got %d, want 0", got)
if msg.Content != "Nested thinking" {
t.Errorf("content: got %q, want %q", msg.Content, "Nested thinking")
}
}

View File

@@ -130,29 +130,6 @@ func (q *Queries) GetCommentInWorkspace(ctx context.Context, arg GetCommentInWor
return i, err
}
const hasAgentCommentedSince = `-- name: HasAgentCommentedSince :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE issue_id = $1
AND author_type = 'agent'
AND author_id = $2
AND created_at >= $3
) AS commented
`
type HasAgentCommentedSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AuthorID pgtype.UUID `json:"author_id"`
Since pgtype.Timestamptz `json:"since"`
}
func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommentedSinceParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentCommentedSince, arg.IssueID, arg.AuthorID, arg.Since)
var commented bool
err := row.Scan(&commented)
return commented, err
}
const listComments = `-- name: ListComments :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2

View File

@@ -44,14 +44,5 @@ UPDATE comment SET
WHERE id = $1
RETURNING *;
-- name: HasAgentCommentedSince :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE issue_id = @issue_id
AND author_type = 'agent'
AND author_id = @author_id
AND created_at >= @since
) AS commented;
-- name: DeleteComment :exec
DELETE FROM comment WHERE id = $1;