diff --git a/.env.example b/.env.example
index 709663e2ff..1662417c41 100644
--- a/.env.example
+++ b/.env.example
@@ -11,17 +11,21 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# DATABASE_MIN_CONNS=5
# Server
-# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
-# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
-# "production" by default, so 888888 is DISABLED — a public instance can't
-# be logged into with any email + 888888.
-# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
-# - Docker self-host on a private network you fully control, or evaluation
-# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
-# enable on a publicly reachable instance.
+# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
+# "production" by default. Local dev can leave it unset.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
+# Optional local/testing shortcut. Empty by default, so there is no fixed
+# verification code. Without RESEND_API_KEY, generated codes print to stdout.
+# If you need deterministic local automation, set a 6-digit value such as
+# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
+MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
+# Prometheus metrics are disabled by default. When enabled, bind to loopback
+# unless you protect the listener with private networking, allowlists, or
+# proxy auth. Do not expose this endpoint through the public app/API ingress.
+# HTTP request metrics start accumulating only when this listener is enabled.
+# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
@@ -45,8 +49,7 @@ MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
# Email (Resend)
-# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
-# master code 888888 works (only when APP_ENV != "production"; see above).
+# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8eaa5959b5..b75c47bfbc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -56,6 +56,12 @@ jobs:
release:
needs: verify
+ # Only run on the canonical upstream repo. Forks don't have the
+ # HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
+ # `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
+ # tag push fails this job (401 against the upstream tap), which makes
+ # downstream CI go red without affecting the actual artifact pipeline.
+ if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
steps:
- name: Checkout
diff --git a/CLAUDE.md b/CLAUDE.md
index 1e5fc6218d..1718a86701 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -136,6 +136,17 @@ make start-worktree # Start using .env.worktree
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
+### Backend Handler UUID Parsing Convention
+
+Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
+
+- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
+- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
+- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
+- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
+
+When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
+
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:
diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md
index 2144d3cbfc..1fdf2a4c30 100644
--- a/CLI_AND_DAEMON.md
+++ b/CLI_AND_DAEMON.md
@@ -146,6 +146,8 @@ The daemon auto-detects these AI CLIs on your PATH:
| Gemini | `gemini` | Google's coding agent |
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
+| Kimi | `kimi` | Moonshot coding agent |
+| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -166,6 +168,7 @@ Daemon behavior is configured via flags or environment variables:
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
+| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
@@ -192,6 +195,10 @@ Agent-specific overrides:
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
+| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
+| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
+| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
+| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
### Self-Hosted Server
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b15178db9d..753dd47f7c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -373,7 +373,8 @@ done
#### 2. Create a test user and token (automated auth)
-In non-production environments the verification code is fixed at `888888`:
+For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
+in your env file before starting the backend:
```bash
curl -s -X POST "$SERVER/auth/send-code" \
@@ -476,7 +477,9 @@ This automatically:
3. Starts and manages its own daemon instance
4. Connects to the local backend
-Login in the Desktop UI with `dev@localhost` and code `888888`.
+Login in the Desktop UI with `dev@localhost` and the generated code from the
+backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
+the backend, you can use `888888` instead.
If the backend runs on a non-default port (worktree), create
`apps/desktop/.env.development.local`:
diff --git a/Dockerfile b/Dockerfile
index 2978968e53..cbb9f23060 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,7 +15,7 @@ COPY server/ ./server/
# Build binaries
ARG VERSION=dev
ARG COMMIT=unknown
-RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
+RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
diff --git a/Makefile b/Makefile
index 40fb1ab5f7..ea2c58f038 100644
--- a/Makefile
+++ b/Makefile
@@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
- echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
+ echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
- echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
+ echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
@@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build: ## Build the server, CLI, and migrate binaries into server/bin
- cd server && go build -o bin/server ./cmd/server
+ cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -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 -o bin/migrate ./cmd/migrate
diff --git a/README.md b/README.md
index edbe069a60..817be3ded9 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
-No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
+No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
@@ -98,7 +98,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
-The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
+The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
### 2. Verify your runtime
@@ -108,7 +108,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
-Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
+Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -162,7 +162,8 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
- Pi, Cursor Agent)
+ Pi, Cursor Agent, Kimi,
+ Kiro CLI)
```
| Layer | Stack |
@@ -170,7 +171,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
-| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
+| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
## Development
diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md
index 653bcd0a22..7e86fb9e42 100644
--- a/SELF_HOSTING.md
+++ b/SELF_HOSTING.md
@@ -26,7 +26,7 @@ multica setup self-host
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
-Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
+Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -67,15 +67,15 @@ Once ready:
### Step 2 — Log In
-Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
+Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
-- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
-- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
+- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
+- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
-> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
+> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 3 — Install CLI & Start Daemon
@@ -98,6 +98,8 @@ You also need at least one AI agent CLI installed:
- Gemini (`gemini` on PATH)
- [Pi](https://pi.dev/) (`pi` on PATH)
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
+- Kimi (`kimi` on PATH)
+- Kiro CLI (`kiro-cli` on PATH)
### b) One-command setup
diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md
index 7d86a56245..eb3ceae0af 100644
--- a/SELF_HOSTING_ADVANCED.md
+++ b/SELF_HOSTING_ADVANCED.md
@@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
-> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
+> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
### Google OAuth (Optional)
@@ -79,6 +79,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
+| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `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` |
@@ -308,6 +309,28 @@ dependency-aware readiness probes and external monitoring that should fail when
the database is unavailable or migrations are not fully applied. `/healthz` is
kept as an alias for operator familiarity.
+## Prometheus Metrics
+
+The backend can expose Prometheus metrics on a separate management listener:
+
+```bash
+METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
+curl http://127.0.0.1:9090/metrics
+```
+
+`METRICS_ADDR` is empty by default, so no metrics listener is started. The
+public API port does not serve `/metrics`; keep it that way for internet-facing
+deployments. HTTP request metrics start accumulating only after the metrics
+listener is enabled. Metrics can reveal internal routes, traffic volume,
+dependency state, and runtime health.
+
+For Docker or Kubernetes deployments, prefer a private scrape path: bind the
+metrics listener to an internal interface and protect it with private
+networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
+`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
+trusted network, for example a host-local mapping such as
+`127.0.0.1:9090:9090`.
+
## Upgrading
```bash
diff --git a/SELF_HOSTING_AI.md b/SELF_HOSTING_AI.md
index 2c534c4151..ea0cbcbdbb 100644
--- a/SELF_HOSTING_AI.md
+++ b/SELF_HOSTING_AI.md
@@ -37,7 +37,7 @@ multica setup self-host
The `multica setup self-host` 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
+2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
3. Discover workspaces automatically
4. Start the daemon in the background
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index 01354a3daa..2680789825 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -37,6 +37,14 @@ linux:
- deb
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
+rpm:
+ # Disable RPM build-id symlinks. Electron apps embed the upstream Electron
+ # binary, whose GNU build-id is identical across every app shipping the same
+ # Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
+ # would own /usr/lib/.build-id/ paths and collide with any other
+ # Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
+ fpm:
+ - "--rpm-rpmbuild-define=_build_id_links none"
win:
target:
- nsis
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 970cafcde0..d29470e5d1 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -1,4 +1,4 @@
-import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
+import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
@@ -214,6 +214,64 @@ if (!gotTheLock) {
mainWindow?.setWindowButtonVisibility(!immersive);
});
+ // IPC: show a native OS notification for a new inbox item. The renderer
+ // only fires this when the app is unfocused (it gates on
+ // `document.hasFocus()`), so we don't fight macOS foreground suppression
+ // here. Clicking the banner focuses the main window and routes to the
+ // inbox item via a renderer-side listener.
+ ipcMain.on(
+ "notification:show",
+ (
+ _event,
+ {
+ slug,
+ itemId,
+ issueKey,
+ title,
+ body,
+ }: {
+ slug: string;
+ itemId: string;
+ issueKey: string;
+ title: string;
+ body: string;
+ },
+ ) => {
+ if (!Notification.isSupported()) return;
+ const notification = new Notification({ title, body });
+ notification.on("click", () => {
+ if (!mainWindow) return;
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.show();
+ mainWindow.focus();
+ // Ship the full context back — the renderer pins the route to the
+ // source workspace (slug), marks the row read (itemId), and uses
+ // issueKey as the ?issue=<…> selector.
+ mainWindow.webContents.send("inbox:open", {
+ slug,
+ itemId,
+ issueKey,
+ });
+ });
+ notification.show();
+ },
+ );
+
+ // IPC: update the dock / taskbar unread badge. Values above 99 render as
+ // "99+". macOS is the primary target (user-visible dock badge); Linux
+ // Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
+ // needs a pre-rendered PNG and is deferred — the OS notification + the
+ // in-app inbox sidebar cover the core UX there for now.
+ ipcMain.on("badge:set", (_event, rawCount: number) => {
+ const count = Math.max(0, Math.floor(rawCount));
+ if (process.platform === "darwin") {
+ const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
+ app.dock?.setBadge(label);
+ } else {
+ app.setBadgeCount(count);
+ }
+ });
+
createWindow();
setupAutoUpdater(() => mainWindow);
diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts
index 97b2c179fa..e9136e4c27 100644
--- a/apps/desktop/src/preload/index.d.ts
+++ b/apps/desktop/src/preload/index.d.ts
@@ -14,6 +14,24 @@ interface DesktopAPI {
openExternal: (url: string) => Promise;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise;
+ /** Show a native OS notification for a new inbox item. */
+ showNotification: (payload: {
+ slug: string;
+ itemId: string;
+ issueKey: string;
+ title: string;
+ body: string;
+ }) => void;
+ /** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
+ setUnreadBadge: (count: number) => void;
+ /** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
+ onInboxOpen: (
+ callback: (payload: {
+ slug: string;
+ itemId: string;
+ issueKey: string;
+ }) => void,
+ ) => () => void;
}
interface DaemonStatus {
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 8843f3f821..78d45de63e 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -50,6 +50,50 @@ const desktopAPI = {
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
+ /**
+ * Show a native OS notification for a new inbox item. Fired from the
+ * renderer only when the app is unfocused — in-focus feedback is the
+ * inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
+ * all round-tripped on click: slug pins routing to the source workspace
+ * (the user may switch workspaces before clicking the banner), itemId
+ * lets the renderer mark the row read, issueKey maps to the inbox URL
+ * param.
+ */
+ showNotification: (payload: {
+ slug: string;
+ itemId: string;
+ issueKey: string;
+ title: string;
+ body: string;
+ }) => ipcRenderer.send("notification:show", payload),
+ /**
+ * Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
+ * above 99 render as "99+" (capping is handled in the main process).
+ */
+ setUnreadBadge: (count: number) =>
+ ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
+ /**
+ * Subscribe to "open this inbox row" requests sent by the main process
+ * when the user clicks an OS notification banner. Returns an unsubscribe
+ * function. The payload echoes the `slug`, `itemId`, and `issueKey` that
+ * were passed to `showNotification`.
+ */
+ onInboxOpen: (
+ callback: (payload: {
+ slug: string;
+ itemId: string;
+ issueKey: string;
+ }) => void,
+ ) => {
+ const handler = (
+ _event: Electron.IpcRendererEvent,
+ payload: { slug: string; itemId: string; issueKey: string },
+ ) => callback(payload);
+ ipcRenderer.on("inbox:open", handler);
+ return () => {
+ ipcRenderer.removeListener("inbox:open", handler);
+ };
+ },
};
interface DaemonStatus {
diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
index f8ecfddf9b..54896fe725 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
@@ -12,9 +12,11 @@ import {
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
+import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
-import { WorkspaceSlugProvider } from "@multica/core/paths";
+import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
+import { useDesktopUnreadBadge } from "@multica/views/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -96,6 +98,38 @@ function useInternalLinkHandler() {
}, []);
}
+/**
+ * Bridge between the renderer and the Electron main process for inbox-level
+ * OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
+ * current workspace's id for the badge hook.
+ *
+ * Two responsibilities:
+ * 1. Mirror the unread inbox count onto the dock/taskbar badge.
+ * 2. When the user clicks an OS notification, open the notified
+ * workspace's inbox focused on that item. The route uses the `slug`
+ * that the notification was *emitted* with — not the currently active
+ * workspace — so a notification from workspace A always opens A's
+ * inbox even if the user has since switched to workspace B. Marking
+ * the row read is handled by InboxPage's selected-item effect, which
+ * covers both click-to-select and URL-param-select paths.
+ */
+function DesktopInboxBridge() {
+ const workspace = useCurrentWorkspace();
+ useDesktopUnreadBadge(workspace?.id ?? null);
+
+ useEffect(() => {
+ return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
+ if (!slug) return;
+ const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
+ window.dispatchEvent(
+ new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
+ );
+ });
+ }, []);
+
+ return null;
+}
+
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
@@ -117,15 +151,18 @@ export function DesktopShell() {
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
+
{slug && } searchSlot={ } />}
{/* Right side: header + content container */}
- {/* Content area with inset styling */}
+ {/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
+ {slug && }
+ {slug && }
diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx
index 12ac363933..93d139ac65 100644
--- a/apps/desktop/src/renderer/src/components/tab-bar.tsx
+++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx
@@ -5,7 +5,6 @@ import {
Bot,
Monitor,
BookOpenText,
- MessageSquare,
Settings,
X,
Plus,
@@ -40,7 +39,6 @@ const TAB_ICONS: Record
= {
Bot,
Monitor,
BookOpenText,
- MessageSquare,
Settings,
};
diff --git a/apps/desktop/src/renderer/src/platform/navigation.tsx b/apps/desktop/src/renderer/src/platform/navigation.tsx
index 01043331a9..494916ed59 100644
--- a/apps/desktop/src/renderer/src/platform/navigation.tsx
+++ b/apps/desktop/src/renderer/src/platform/navigation.tsx
@@ -115,10 +115,10 @@ export function DesktopNavigationProvider({
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
// Mirror the active tab router's full location (pathname + search) so
- // shell-level consumers of useNavigation() can read URL search params.
- // Must stay in sync with TabNavigationProvider below; a partial shape
- // here (just pathname) silently broke focus-mode anchor resolution on
- // `/inbox?issue=…`.
+ // shell-level consumers of useNavigation() — ChatWindow in particular —
+ // can read URL search params. Must stay in sync with TabNavigationProvider
+ // below; a partial shape here (just pathname) silently broke focus-mode
+ // anchor resolution on `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",
diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx
index 406585ae4c..09f65415b5 100644
--- a/apps/desktop/src/renderer/src/routes.tsx
+++ b/apps/desktop/src/renderer/src/routes.tsx
@@ -20,7 +20,6 @@ import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
-import { ChatPage } from "@multica/views/chat";
import { SettingsPage } from "@multica/views/settings";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
@@ -138,7 +137,6 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Agent" },
},
{ path: "inbox", element: , handle: { title: "Inbox" } },
- { path: "chat", element: , handle: { title: "Chat" } },
{
path: "settings",
element: (
diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts
index d31454328b..6ca66893d8 100644
--- a/apps/desktop/src/renderer/src/stores/tab-store.ts
+++ b/apps/desktop/src/renderer/src/stores/tab-store.ts
@@ -101,7 +101,6 @@ interface TabStore {
const ROUTE_ICONS: Record = {
inbox: "Inbox",
- chat: "MessageSquare",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",
diff --git a/apps/docs/content/docs/agents-create.mdx b/apps/docs/content/docs/agents-create.mdx
index 3a9e48a1a5..2d4a7af5c5 100644
--- a/apps/docs/content/docs/agents-create.mdx
+++ b/apps/docs/content/docs/agents-create.mdx
@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
## Pick an AI coding tool
-Each runtime is backed by a specific AI coding tool. Multica supports 10 of them. The most common choices:
+Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
| Tool | Good for |
|---|---|
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 10 of them
| **Copilot** | Teams leveraging their GitHub account entitlements |
| **Gemini** | Users in the Google ecosystem |
-The other five (Hermes, Kimi, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
+The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
## Writing system instructions
@@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks.
## Next steps
- [Skills](/skills) — attach knowledge packs to an agent
-- [AI coding tools comparison](/providers) — full capability matrix across all 10 tools
+- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
diff --git a/apps/docs/content/docs/agents-create.zh.mdx b/apps/docs/content/docs/agents-create.zh.mdx
index fb0eac6f3a..57eb645c7e 100644
--- a/apps/docs/content/docs/agents-create.zh.mdx
+++ b/apps/docs/content/docs/agents-create.zh.mdx
@@ -21,7 +21,7 @@ multica agent create
## 选一款 AI 编程工具
-运行时背后是一款具体的 AI 编程工具。Multica 支持 10 款,最常用的几款:
+运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
| 工具 | 适合 |
|---|---|
@@ -31,7 +31,7 @@ multica agent create
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
-另外 5 款(Hermes、Kimi、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
+另外 6 款(Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
## 写系统指令
@@ -123,5 +123,5 @@ claude --model --max-turns 100 --append-system-prompt "always respond in
## 下一步
- [Skills](/skills) —— 给智能体挂专业知识包
-- [AI 编程工具对照](/providers) —— 10 款工具的完整能力差别
+- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来
diff --git a/apps/docs/content/docs/auth-setup.mdx b/apps/docs/content/docs/auth-setup.mdx
index 78aaaf6acf..7d8fb29085 100644
--- a/apps/docs/content/docs/auth-setup.mdx
+++ b/apps/docs/content/docs/auth-setup.mdx
@@ -1,6 +1,6 @@
---
title: Sign-in and signup configuration
-description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap.
+description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -27,17 +27,24 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
-## The 888888 trap
+## Fixed local testing codes
-**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.**
+**Do not enable a fixed verification code on a publicly reachable instance.**
-Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification.
+The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
-**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself.
+Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
+
+```bash
+APP_ENV=development
+MULTICA_DEV_VERIFICATION_CODE=888888
+```
+
+This shortcut is ignored when `APP_ENV=production`.
-To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**.
+Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
## Google OAuth configuration
diff --git a/apps/docs/content/docs/auth-setup.zh.mdx b/apps/docs/content/docs/auth-setup.zh.mdx
index aabe40d829..7ce8115361 100644
--- a/apps/docs/content/docs/auth-setup.zh.mdx
+++ b/apps/docs/content/docs/auth-setup.zh.mdx
@@ -1,12 +1,12 @@
---
title: 登录与注册配置
-description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱。
+description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
-Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱。
+Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及本地测试验证码怎么安全使用。
上面用到的环境变量的清单见 [环境变量](/environment-variables);token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
@@ -27,17 +27,24 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
-## 888888 陷阱
+## 固定本地测试验证码
-**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。**
+**不要在公网可访问实例上启用固定验证码。**
-Multica 有一个开发用的主验证码(master code)`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过。
+旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
-**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。
+不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
+
+```bash
+APP_ENV=development
+MULTICA_DEV_VERIFICATION_CODE=888888
+```
+
+`APP_ENV=production` 时这个快捷码会被忽略。
-检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`,**整个实例处于完全开放状态**。
+生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`APP_ENV` 默认就是 `production`。
## 怎么配 Google OAuth
diff --git a/apps/docs/content/docs/auth-tokens.mdx b/apps/docs/content/docs/auth-tokens.mdx
index 3472f9f3a2..27aea8704b 100644
--- a/apps/docs/content/docs/auth-tokens.mdx
+++ b/apps/docs/content/docs/auth-tokens.mdx
@@ -38,7 +38,7 @@ In day-to-day use you'll only touch the first two directly. The **[daemon](/daem
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
-**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup).
+**Self-hosting operators, take note**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. See [Self-host auth configuration](/auth-setup).
### Google OAuth
diff --git a/apps/docs/content/docs/auth-tokens.zh.mdx b/apps/docs/content/docs/auth-tokens.zh.mdx
index 0c04e01ba5..e907bfb23f 100644
--- a/apps/docs/content/docs/auth-tokens.zh.mdx
+++ b/apps/docs/content/docs/auth-tokens.zh.mdx
@@ -38,7 +38,7 @@ Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令
2. 输入验证码,server 签发 JWT cookie(浏览器)或交换出 PAT(CLI)
-**自部署运维注意**:如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
+**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。
### Google OAuth
diff --git a/apps/docs/content/docs/cli/installation.zh.mdx b/apps/docs/content/docs/cli/installation.zh.mdx
index b5c8f02ff4..81ae5bbada 100644
--- a/apps/docs/content/docs/cli/installation.zh.mdx
+++ b/apps/docs/content/docs/cli/installation.zh.mdx
@@ -78,7 +78,7 @@ multica daemon status
Confirm:
1. Status is `running`
-2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
+2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, `kiro`, or `pi`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:
@@ -88,6 +88,8 @@ If the agents list is empty, install at least one supported AI agent CLI:
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)
+- Kimi (`kimi`)
+- Kiro CLI (`kiro-cli`)
Then restart the daemon:
diff --git a/apps/docs/content/docs/cli/reference.zh.mdx b/apps/docs/content/docs/cli/reference.zh.mdx
index f43c534cd3..f87579c815 100644
--- a/apps/docs/content/docs/cli/reference.zh.mdx
+++ b/apps/docs/content/docs/cli/reference.zh.mdx
@@ -92,6 +92,10 @@ The daemon auto-detects these AI CLIs on your PATH:
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
+| Kimi | `kimi` | Moonshot coding agent |
+| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
+| Pi | `pi` | Inflection coding agent |
+| Cursor Agent | `cursor-agent` | Cursor coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -134,6 +138,14 @@ Agent-specific overrides:
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
+| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
+| `MULTICA_PI_MODEL` | Override the Pi model used |
+| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
+| `MULTICA_CURSOR_MODEL` | Override the Cursor model used |
+| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
+| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
+| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
+| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
### Self-Hosted Server
diff --git a/apps/docs/content/docs/cloud-quickstart.mdx b/apps/docs/content/docs/cloud-quickstart.mdx
index bdd1725261..01d86193d1 100644
--- a/apps/docs/content/docs/cloud-quickstart.mdx
+++ b/apps/docs/content/docs/cloud-quickstart.mdx
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
-One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
+One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
## 1. Create an account
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
- [Tasks](/tasks) — task lifecycle and retry rules
-- [AI coding tools compared](/providers) — capability differences across the 10 tools
+- [AI coding tools compared](/providers) — capability differences across the 11 tools
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
- [Self-host quickstart](/self-host-quickstart) — run your own backend
diff --git a/apps/docs/content/docs/cloud-quickstart.zh.mdx b/apps/docs/content/docs/cloud-quickstart.zh.mdx
index 3a2c4d77c1..25baabda9a 100644
--- a/apps/docs/content/docs/cloud-quickstart.zh.mdx
+++ b/apps/docs/content/docs/cloud-quickstart.zh.mdx
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
-前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
+前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
## 1. 注册账号
@@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket)显示进度——不需要刷新
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
- [执行任务](/tasks) —— 任务生命周期、重试规则
-- [AI 编程工具对照](/providers) —— 10 款工具的能力差异
+- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套
diff --git a/apps/docs/content/docs/daemon-runtimes.mdx b/apps/docs/content/docs/daemon-runtimes.mdx
index 9aac2c25a3..b60c07f5bc 100644
--- a/apps/docs/content/docs/daemon-runtimes.mdx
+++ b/apps/docs/content/docs/daemon-runtimes.mdx
@@ -21,7 +21,7 @@ multica daemon start
On startup it does four things:
1. Reads the credentials saved when you logged in
-2. Detects AI coding tools installed on your `PATH` (10 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
+2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. Registers itself with the server, along with a runtime for each detected tool
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
@@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting).
## Next
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
-- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools
+- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools
diff --git a/apps/docs/content/docs/daemon-runtimes.zh.mdx b/apps/docs/content/docs/daemon-runtimes.zh.mdx
index 7553b75ed3..3de87d2a55 100644
--- a/apps/docs/content/docs/daemon-runtimes.zh.mdx
+++ b/apps/docs/content/docs/daemon-runtimes.zh.mdx
@@ -21,7 +21,7 @@ multica daemon start
启动后它会做四件事:
1. 读取你登录时保存的凭证
-2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 10 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
+2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
## 下一步
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
-- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照
+- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照
diff --git a/apps/docs/content/docs/desktop-app.mdx b/apps/docs/content/docs/desktop-app.mdx
index 6b91655afd..a988c092f8 100644
--- a/apps/docs/content/docs/desktop-app.mdx
+++ b/apps/docs/content/docs/desktop-app.mdx
@@ -66,12 +66,25 @@ Grab the installer for your platform from the [Multica downloads page](https://m
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
-
-**Which backend Desktop connects to** is determined by the address you select at sign-in. It defaults to Multica Cloud; if you're running self-hosted, click "Connect to a self-hosted instance" on the first login screen and fill in your server address.
+
+**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
+
+```bash
+git clone https://github.com/multica-ai/multica.git
+cd multica
+# Edit apps/desktop/.env.production:
+# VITE_API_URL=https://api.your-domain
+# VITE_WS_URL=wss://api.your-domain/ws
+# VITE_APP_URL=https://your-domain
+pnpm install
+pnpm --filter @multica/desktop package
+```
+
+If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
## Next steps
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
-- [Self-Host Quickstart](/self-host-quickstart) — connecting Desktop to a self-hosted backend
+- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
diff --git a/apps/docs/content/docs/desktop-app.zh.mdx b/apps/docs/content/docs/desktop-app.zh.mdx
index 45656f43f0..30e6bd8034 100644
--- a/apps/docs/content/docs/desktop-app.zh.mdx
+++ b/apps/docs/content/docs/desktop-app.zh.mdx
@@ -66,12 +66,25 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
-
-**桌面版连哪个后端** 由登录时选的地址决定。默认连 Multica Cloud;如果你用自部署版本,在首次登录页点"连接到自部署实例"填你的 server 地址即可。
+
+**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
+
+```bash
+git clone https://github.com/multica-ai/multica.git
+cd multica
+# 编辑 apps/desktop/.env.production:
+# VITE_API_URL=https://api.your-domain
+# VITE_WS_URL=wss://api.your-domain/ws
+# VITE_APP_URL=https://your-domain
+pnpm install
+pnpm --filter @multica/desktop package
+```
+
+不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
## 下一步
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
-- [Self-Host Quickstart](/self-host-quickstart) —— Desktop 连自部署后端
+- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx
index 81e03c6a83..88b4cb60d4 100644
--- a/apps/docs/content/docs/environment-variables.mdx
+++ b/apps/docs/content/docs/environment-variables.mdx
@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup).
-## The five required at startup
+## Core server variables
-These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly.
+These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones explicitly.
| Variable | Default | Required in production? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** |
| `PORT` | `8080` | No (unless you change the port) |
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) |
-| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) |
+| `APP_ENV` | empty | **Yes** (must be `production`) |
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
+| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) |
-**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap).
+**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`.
### Database connection pool
diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx
index a2388bc740..abde447ed7 100644
--- a/apps/docs/content/docs/environment-variables.zh.mdx
+++ b/apps/docs/content/docs/environment-variables.zh.mdx
@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
-## 启动必填的五个
+## 核心 server 环境变量
-这五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。
+这些是你部署前必须考虑的核心变量——有些有默认值能让 server 启动,但生产环境里你应该显式配置必填项。
| 环境变量 | 默认值 | 生产必须设? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** |
| `PORT` | `8080` | 否(除非换端口)|
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)|
-| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱)|
+| `APP_ENV` | 空 | **是**(必须 `production`)|
| `FRONTEND_ORIGIN` | 空 | **是**(self-host 要填你自己的域名)|
+| `MULTICA_DEV_VERIFICATION_CODE` | 空 | 否(生产必须保持为空)|
-**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码(master code)`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
+**生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。** 固定本地测试验证码默认关闭;如果你设置 `MULTICA_DEV_VERIFICATION_CODE=888888`,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用这个固定值登录。`APP_ENV=production` 时该快捷码会被忽略。
### 数据库连接池
diff --git a/apps/docs/content/docs/getting-started/self-hosting.zh.mdx b/apps/docs/content/docs/getting-started/self-hosting.zh.mdx
index 96700ba00d..acf33b9962 100644
--- a/apps/docs/content/docs/getting-started/self-hosting.zh.mdx
+++ b/apps/docs/content/docs/getting-started/self-hosting.zh.mdx
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
-This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
+This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -68,16 +68,16 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
### Step 2 — Log In
-Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
+Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
-- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
-- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
+- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
+- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
-**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
+**Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 3 — Install CLI & Start Daemon
diff --git a/apps/docs/content/docs/how-multica-works.mdx b/apps/docs/content/docs/how-multica-works.mdx
index 413c84392c..659c34ca29 100644
--- a/apps/docs/content/docs/how-multica-works.mdx
+++ b/apps/docs/content/docs/how-multica-works.mdx
@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
-- **AI coding tools** — one of the ten (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
+- **AI coding tools** — one of the eleven (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.
diff --git a/apps/docs/content/docs/how-multica-works.zh.mdx b/apps/docs/content/docs/how-multica-works.zh.mdx
index 705889bfac..280eba860e 100644
--- a/apps/docs/content/docs/how-multica-works.zh.mdx
+++ b/apps/docs/content/docs/how-multica-works.zh.mdx
@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub,把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
- **守护进程**(daemon)——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server,开始每 3 秒领一次任务、每 15 秒发一次心跳。
-- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 十款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
+- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 11 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用;Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
index ad94c18002..ff2e9a731e 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
-- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Ten are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
+- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Eleven are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.
diff --git a/apps/docs/content/docs/index.zh.mdx b/apps/docs/content/docs/index.zh.mdx
index a298044dfc..a74ddc311a 100644
--- a/apps/docs/content/docs/index.zh.mdx
+++ b/apps/docs/content/docs/index.zh.mdx
@@ -13,7 +13,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
-- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置十种:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
+- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
diff --git a/apps/docs/content/docs/providers.mdx b/apps/docs/content/docs/providers.mdx
index 811d159fbf..a2ece008b1 100644
--- a/apps/docs/content/docs/providers.mdx
+++ b/apps/docs/content/docs/providers.mdx
@@ -1,11 +1,11 @@
---
title: AI coding tools matrix
-description: Multica supports 10 AI coding tools; they implement the same interface, but the capability details diverge significantly.
+description: Multica supports 11 AI coding tools; they implement the same interface, but the capability details diverge significantly.
---
import { Callout } from "fumadocs-ui/components/callout";
-Multica ships with built-in support for **10 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
+Multica ships with built-in support for **11 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
@@ -20,6 +20,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static |
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery |
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
+| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery |
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
@@ -28,7 +29,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
### Claude Code
-From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 10 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
+From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
### Codex
@@ -54,6 +55,10 @@ From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Sessio
From Moonshot, aimed at the Chinese market. Shares the ACP protocol with Hermes, but the skill path `.kimi/skills/` is Kimi CLI's native discovery mechanism — different from Hermes's fallback.
+### Kiro CLI
+
+From Amazon. Uses ACP over stdio via `kiro-cli acp`. Session resumption works through ACP `session/load`, model selection works through `session/set_model`, and skills are copied into `.kiro/skills/` for native project-level discovery.
+
### OpenCode
From SST, open source. Dynamically discovers available models (scans the CLI's configuration file). Session resumption works. **Suitable for tinkerers who want to customize their model catalog.**
@@ -72,7 +77,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
| Status | Tools | Meaning |
|---|---|---|
-| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
+| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
| ❌ None | Gemini | The CLI has no resume mechanism |
@@ -80,7 +85,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
## MCP configuration: only Claude Code actually reads it
-**Of the 10 tools, only Claude Code actually consumes `mcp_config`**. The other 9 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
+**Of the 11 tools, only Claude Code actually consumes `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only.
@@ -97,6 +102,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
| Copilot | `.github/skills/` | ✅ Native |
| Cursor | `.cursor/skills/` | ✅ Native |
| Kimi | `.kimi/skills/` | ✅ Native |
+| Kiro CLI | `.kiro/skills/` | ✅ Native |
| OpenCode | `.config/opencode/skills/` | ✅ Native |
| Pi | `.pi/skills/` | ✅ Native |
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
diff --git a/apps/docs/content/docs/providers.zh.mdx b/apps/docs/content/docs/providers.zh.mdx
index d230f2c7ea..98b5105895 100644
--- a/apps/docs/content/docs/providers.zh.mdx
+++ b/apps/docs/content/docs/providers.zh.mdx
@@ -1,11 +1,11 @@
---
title: AI 编程工具对照
-description: Multica 支持 10 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
+description: Multica 支持 11 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
---
import { Callout } from "fumadocs-ui/components/callout";
-Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
+Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
@@ -20,6 +20,7 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
+| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
@@ -28,7 +29,7 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接
### Claude Code
-Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **10 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
+Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **11 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
### Codex
@@ -54,6 +55,10 @@ Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层)。会话
Moonshot 出品,中国市场向。和 Hermes 共享 ACP 协议,但 skill 路径 `.kimi/skills/` 是 Kimi CLI 的原生发现机制——和 Hermes 的 fallback 不一样。
+### Kiro CLI
+
+Amazon 出品。通过 `kiro-cli acp` 使用 ACP stdio 协议。会话恢复走 ACP `session/load`,模型选择走 `session/set_model`,skill 会复制到 `.kiro/skills/` 让 Kiro 做项目级原生发现。
+
### OpenCode
SST 出品,开源。动态发现可用模型(扫 CLI 的配置文件)。会话恢复真用。**适合爱折腾、想自定义模型目录**的开发者。
@@ -72,7 +77,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| 状态 | 工具 | 含义 |
|---|---|---|
-| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
+| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
| ❌ 无 | Gemini | CLI 无 resume 机制 |
@@ -80,7 +85,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
## MCP 配置:只有 Claude Code 真的读
-**10 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 9 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
+**11 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。
@@ -97,6 +102,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| Copilot | `.github/skills/` | ✅ 原生 |
| Cursor | `.cursor/skills/` | ✅ 原生 |
| Kimi | `.kimi/skills/` | ✅ 原生 |
+| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
| Pi | `.pi/skills/` | ✅ 原生 |
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx
index e5be0cf432..6acb73e188 100644
--- a/apps/docs/content/docs/self-host-quickstart.mdx
+++ b/apps/docs/content/docs/self-host-quickstart.mdx
@@ -45,19 +45,19 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
-## 2. Important: set `APP_ENV` to `production`
+## 2. Important: keep production safety on
-**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** — this prevents the development "master code `888888`" from being enabled on an instance you've exposed to the public internet.
+**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** and leaves `MULTICA_DEV_VERIFICATION_CODE` empty, so there is no fixed code on public instances.
-**But if your `.env` leaves `APP_ENV` empty or sets it to another value**, `888888` is enabled — **anyone can log in as any email by typing `888888` as the verification code**. See [Auth setup → The 888888 trap](/auth-setup#the-888888-trap).
+Only set `MULTICA_DEV_VERIFICATION_CODE` for local or private test automation. If a fixed code is enabled while `APP_ENV` is non-production, anyone who can request a code can sign in with that fixed value. See [Auth setup → Fixed local testing codes](/auth-setup#fixed-local-testing-codes).
-Before any public deployment, make sure `.env` has `APP_ENV=production`.
+Before any public deployment, make sure `.env` has `APP_ENV=production` and `MULTICA_DEV_VERIFICATION_CODE` is empty.
## 3. Configure the email service (optional but recommended)
-Without email configured, your users can't receive verification codes — **unless `APP_ENV != production`, in which case `888888` works** (see the warning above).
+Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
To actually send verification emails:
@@ -80,6 +80,7 @@ Open [http://localhost:3000](http://localhost:3000):
- Enter your email
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
+- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
- Log in and create your first workspace
## 5. Point the CLI at your own server
@@ -115,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
- [Environment variables](/environment-variables) — full env reference
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
- [Troubleshooting](/troubleshooting) — start here when things go wrong
-- [Desktop app](/desktop-app) — the desktop app can also connect to your self-hosted backend
+- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
diff --git a/apps/docs/content/docs/self-host-quickstart.zh.mdx b/apps/docs/content/docs/self-host-quickstart.zh.mdx
index b333fd8ebd..8321de6484 100644
--- a/apps/docs/content/docs/self-host-quickstart.zh.mdx
+++ b/apps/docs/content/docs/self-host-quickstart.zh.mdx
@@ -44,19 +44,19 @@ make selfhost
- **前端**:[http://localhost:3000](http://localhost:3000)
- **后端**:[http://localhost:8080](http://localhost:8080)
-## 2. 重要:改 `APP_ENV` 成 `production`
+## 2. 重要:保持生产安全配置
-**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**——这防止开发用的"万能验证码 `888888`"在你公网暴露的实例上启用。
+**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**,并让 `MULTICA_DEV_VERIFICATION_CODE` 为空,所以公网实例默认没有固定验证码。
-**但如果你的 `.env` 里把 `APP_ENV` 留空或改成其他值**,`888888` 会被启用——**任何人输入任何邮箱 + `888888` 都能登录**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
+只在本地或私有测试自动化里设置 `MULTICA_DEV_VERIFICATION_CODE`。如果在 `APP_ENV` 非 production 时启用了固定验证码,任何能请求验证码的人都能用这个固定值登录。详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)。
-公网部署前一定检查 `.env` 里 `APP_ENV=production`。
+公网部署前一定检查 `.env` 里 `APP_ENV=production`,且 `MULTICA_DEV_VERIFICATION_CODE` 为空。
## 3. 配置邮件服务(可选但推荐)
-如果不配邮件,你的用户无法收到验证码——**但如果 `APP_ENV != production` 你可以用 `888888` 登录**(见上方警告)。
+如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
要真的发验证码邮件:
@@ -79,6 +79,7 @@ make selfhost
- 输入你的邮箱
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
+- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
- 登录后创建第一个工作区
## 5. 连接命令行工具到你自己的 server
@@ -114,4 +115,4 @@ multica setup self-host
- [环境变量](/environment-variables) —— 完整 env 清单
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
- [故障排查](/troubleshooting) —— 遇到问题先来这里
-- [桌面应用](/desktop-app) —— 桌面应用也能连你的自部署后端
+- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
diff --git a/apps/docs/content/docs/skills.mdx b/apps/docs/content/docs/skills.mdx
index 5d799d6cf3..1971a56e9e 100644
--- a/apps/docs/content/docs/skills.mdx
+++ b/apps/docs/content/docs/skills.mdx
@@ -64,4 +64,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T
- [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline
- [Executing tasks](/tasks) — the full lifecycle of one "agent work session"
-- [AI coding tools comparison](/providers) — full comparison of all 10 tools (including each one's skill injection path)
+- [AI coding tools comparison](/providers) — full comparison of all 11 tools (including each one's skill injection path)
diff --git a/apps/docs/content/docs/skills.zh.mdx b/apps/docs/content/docs/skills.zh.mdx
index ef516b85f1..47578371ab 100644
--- a/apps/docs/content/docs/skills.zh.mdx
+++ b/apps/docs/content/docs/skills.zh.mdx
@@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
- [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线
- [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期
-- [AI 编程工具对照](/providers) —— 10 款工具的完整对比(含每款的 Skill 注入路径)
+- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径)
diff --git a/apps/docs/content/docs/tasks.mdx b/apps/docs/content/docs/tasks.mdx
index b10596ffc2..5952e80f8a 100644
--- a/apps/docs/content/docs/tasks.mdx
+++ b/apps/docs/content/docs/tasks.mdx
@@ -100,7 +100,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
But **which AI coding tools actually support this** varies a lot:
-- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, OpenCode, OpenClaw, Pi
+- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ⚠️ **Code exists but unusable** — Codex, Cursor
- ❌ **No support** — Gemini
@@ -108,5 +108,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
## Next
-- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools (including the exact session-resumption status)
+- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools (including the exact session-resumption status)
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task
diff --git a/apps/docs/content/docs/tasks.zh.mdx b/apps/docs/content/docs/tasks.zh.mdx
index 916716d8ce..f682ef5e0f 100644
--- a/apps/docs/content/docs/tasks.zh.mdx
+++ b/apps/docs/content/docs/tasks.zh.mdx
@@ -100,7 +100,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
但**哪些 AI 编程工具真的支持**差别很大:
-- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、OpenCode、OpenClaw、Pi
+- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
- ❌ **不支持**——Gemini
@@ -108,5 +108,5 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
## 下一步
-- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
+- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式
diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx
index 2b0a0a9be4..1c8864e3c2 100644
--- a/apps/docs/content/docs/troubleshooting.mdx
+++ b/apps/docs/content/docs/troubleshooting.mdx
@@ -108,28 +108,29 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
-## Verification code `888888` doesn't work
+## Fixed local test code doesn't work
-**Symptom**: on a self-hosted instance, you try to sign in with the development-only master code `888888` and it's rejected with `invalid or expired code`.
+**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.
**Likely causes** (mutually exclusive):
-1. **`APP_ENV=production`** — this is the **correct** production configuration; `888888` is **disabled** when `APP_ENV=production`. Intentional design, not a bug
-2. **You received a real code via Resend** — if Resend is configured, the server sent an actual email; `888888` is only a dev fallback
+1. **`MULTICA_DEV_VERIFICATION_CODE` is empty** — fixed codes are disabled by default
+2. **`APP_ENV=production`** — this is the **correct** production configuration; fixed local test codes are ignored in production
+3. **The configured code is not 6 digits** — the shortcut only accepts a 6-digit value
**How to diagnose**:
```bash
-cat .env | grep APP_ENV # inspect current config
-docker exec env | grep APP_ENV # docker deployment
+cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
+docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
```
Check your inbox (including spam) for the real verification code.
**How to fix**:
-- In production, you shouldn't be using `888888` at all — configure Resend and use real codes
-- **For local development or internal testing**, if you need `888888`, ensure `APP_ENV` is unset or not `production` — but **never** run a public instance this way (see [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap))
+- In production, leave `MULTICA_DEV_VERIFICATION_CODE` empty — configure Resend and use real codes
+- For local development or internal testing, either copy the generated code from server logs or set `APP_ENV=development` plus `MULTICA_DEV_VERIFICATION_CODE=888888` — never enable a fixed code on a public instance (see [Sign-in and signup configuration → Fixed local testing codes](/auth-setup#fixed-local-testing-codes))
## Port conflicts
diff --git a/apps/docs/content/docs/troubleshooting.zh.mdx b/apps/docs/content/docs/troubleshooting.zh.mdx
index 5b3390e34a..ef73228dd3 100644
--- a/apps/docs/content/docs/troubleshooting.zh.mdx
+++ b/apps/docs/content/docs/troubleshooting.zh.mdx
@@ -108,28 +108,29 @@ multica issue show # 看 task 历史
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
-## 验证码是 `888888` 但登不进去
+## 固定本地测试验证码登不进去
-**症状**:自部署实例,想用开发用的主验证码 `888888` 登录,但被拒 `invalid or expired code`。
+**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。
-**可能原因**(这俩互斥):
+**可能原因**(互斥):
-1. **`APP_ENV=production`** —— 这正是你**应该**的生产配置;`888888` 在 `APP_ENV=production` 时**被禁用**。这是刻意设计,不是 bug
-2. **你在 Resend 收到了真实验证码** —— 如果 Resend 已配,server 实际发了真邮件,`888888` 只作为 dev fallback
+1. **`MULTICA_DEV_VERIFICATION_CODE` 为空** —— 固定验证码默认关闭
+2. **`APP_ENV=production`** —— 这是正确的生产配置;固定本地测试验证码在 production 中会被忽略
+3. **配置的验证码不是 6 位数字** —— 这个快捷码只接受 6 位数字
**怎么查**:
```bash
-cat .env | grep APP_ENV # 看当前配置
-docker exec env | grep APP_ENV # docker 部署
+cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
+docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
```
检查邮箱(含 spam)看有没有收到真实验证码。
**怎么修**:
-- 生产环境你本来就不该用 `888888`—— 配好 Resend 用真实验证码
-- **本地开发或内网测试**若需要 `888888`,确保 `APP_ENV` 未设或不是 `production`——但**绝对不要**这样跑公网实例(详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱))
+- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
+- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码))
## 端口冲突
diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx
deleted file mode 100644
index dfeb9b95c0..0000000000
--- a/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { ChatPage as default } from "@multica/views/chat";
diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
index 2a4a7fe690..43769cc98e 100644
--- a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
+++ b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
@@ -3,6 +3,7 @@
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
+import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -13,6 +14,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
extra={
<>
+
+
>
}
diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts
index a1a62535be..8529ba4798 100644
--- a/apps/web/features/landing/i18n/en.ts
+++ b/apps/web/features/landing/i18n/en.ts
@@ -283,6 +283,51 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
+ {
+ version: "0.2.19",
+ date: "2026-04-28",
+ title: "Kiro CLI Runtime, Desktop Notifications & Issue Label Filter",
+ changes: [],
+ features: [
+ "Kiro CLI added as a local agent runtime option",
+ "macOS dock badge for unread issues, plus a native notification when the window is unfocused — click to jump straight to the issue",
+ "Issue list now supports filtering by label, combinable with status / priority / assignee",
+ "Daemon receives task wakeups over WebSocket — task startup latency drops noticeably",
+ ],
+ improvements: [
+ "List and board status group headers are simpler, with clearer color cues",
+ "Author-written markdown links are preserved through linkify",
+ "Label attach now applies optimistically, no server round-trip wait",
+ "Mention picker's issue search refreshes as you type",
+ ],
+ fixes: [
+ "Deleting a comment now cancels any agent task it triggered — no more ghost runs",
+ "Stalled Codex turns now time out instead of holding the slot",
+ "Windows daemon no longer dies when the parent shell closes",
+ "Agent-to-agent mention threads no longer cause feedback loops",
+ ],
+ },
+ {
+ version: "0.2.18",
+ date: "2026-04-27",
+ title: "Issue Labels, Labs Tab & Sidebar Invite Dot",
+ changes: [],
+ features: [
+ "Issue labels — color-code and filter issues across list, board and detail views",
+ "Labs settings tab for experimental toggles",
+ "Sidebar shows a dot when you have an unread workspace invite",
+ ],
+ improvements: [
+ "Project picker now shows the selected project's icon",
+ "Sidebar parent items stay highlighted on detail pages",
+ "Self-hosted deployments correctly honor signup gating env vars",
+ ],
+ fixes: [
+ "Agent comments preserve line breaks again",
+ "Desktop RPM no longer conflicts with Slack / VS Code on Fedora",
+ "Windows agents handle multi-line prompts correctly",
+ ],
+ },
{
version: "0.2.17",
date: "2026-04-26",
diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts
index 2e403ec9b4..2ac6b8d019 100644
--- a/apps/web/features/landing/i18n/zh.ts
+++ b/apps/web/features/landing/i18n/zh.ts
@@ -283,6 +283,51 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
+ {
+ version: "0.2.19",
+ date: "2026-04-28",
+ title: "Kiro CLI Runtime、桌面通知红点与 Issue 标签过滤",
+ changes: [],
+ features: [
+ "新增 Kiro CLI 作为本地 Agent runtime 选项",
+ "macOS Dock 显示未读 Issue 红点;窗口失焦时弹出原生通知,点击直达对应 Issue",
+ "Issue 列表新增 Label 过滤,可与状态、优先级、Assignee 等组合使用",
+ "Daemon 通过 WebSocket 接收任务唤醒,任务起跑延迟显著降低",
+ ],
+ improvements: [
+ "List/Board 视图的状态分组 header 更简洁,颜色提示更清晰",
+ "评论中作者手写的 Markdown 链接不再被自动 linkify 替换",
+ "添加 Label 现在乐观更新,无需等待服务端往返",
+ "Mention 输入时的 Issue 搜索结果会随着输入实时刷新",
+ ],
+ fixes: [
+ "Comment 被删除时会取消已触发的 Agent 任务,不再有幽灵 run",
+ "Codex 卡住的对话回合会超时退出,避免占用配额",
+ "Windows Daemon 不再随父 shell 关闭被一同杀掉",
+ "Agent 之间的 mention 不再相互触发,避免死循环",
+ ],
+ },
+ {
+ version: "0.2.18",
+ date: "2026-04-27",
+ title: "Issue 标签、Labs 设置页与邀请红点",
+ changes: [],
+ features: [
+ "Issue 标签——给 Issue 上色、分类,列表、看板和详情页都能用",
+ "新增 Labs 设置页,集中放实验性开关",
+ "有未读工作区邀请时,侧边栏会出现红点提示",
+ ],
+ improvements: [
+ "Project 选择器会显示当前所选 Project 的图标",
+ "进入详情页时,侧边栏父级菜单保持高亮",
+ "自托管部署正确读取注册放行相关的环境变量",
+ ],
+ fixes: [
+ "Agent 评论的换行恢复正常显示",
+ "桌面端 RPM 不再与 Slack / VS Code 在 Fedora 上冲突",
+ "Windows 下 Agent 能正确处理多行 prompt",
+ ],
+ },
{
version: "0.2.17",
date: "2026-04-26",
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index c4b7818fbb..9edff1c7ca 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
index bdbd01afac..acb0df47d3 100644
--- a/docker-compose.selfhost.yml
+++ b/docker-compose.selfhost.yml
@@ -40,6 +40,7 @@ services:
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
PORT: "8080"
+ METRICS_ADDR: ${METRICS_ADDR:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
@@ -55,7 +56,11 @@ services:
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
+ MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
+ ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
+ ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
+ ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
restart: unless-stopped
frontend:
diff --git a/docs/docs-outline.md b/docs/docs-outline.md
index 61b727fbdb..ded80ff7fc 100644
--- a/docs/docs-outline.md
+++ b/docs/docs-outline.md
@@ -147,7 +147,7 @@ multica issue assign --agent
**关键约定**:
-- **Callout**:`... `。warning 用于陷阱(如 888888),info 用于补充说明,tip 用于最佳实践
+- **Callout**:`... `。warning 用于陷阱(如固定测试验证码),info 用于补充说明,tip 用于最佳实践
- **代码块**:shell 命令用 \`\`\`bash;配置用 \`\`\`yaml / \`\`\`env;JSON 用 \`\`\`json
- **Cross-link**:用 markdown 链接 `[显示文字](/docs/page-slug)`,不要写成 "详见 Tasks 章节"
- **表格**:有 3 行以上对照才用表格,不要 1-2 行也用
@@ -723,11 +723,11 @@ multica issue assign --agent
> **合并说明**:原 7.3 Auth Setup + 7.10 Signup Controls 合并。
-- **Source files**: `server/internal/handler/auth.go`(APP_ENV 判断 + checkSignupAllowed), `.env.example`(auth 相关注释)
+- **Source files**: `server/internal/handler/auth.go`(固定测试验证码 + checkSignupAllowed), `.env.example`(auth 相关注释)
- **目标读者**: self-host 运维
- **叙事位置**: self-host 的 auth 配置。
- **写什么**(1500-2000 字):
- - **🚨 超醒目 warning block**:`APP_ENV=production` 必须设置,否则 verification code 恒为 `888888`(任何人登录任何账号)
+ - **🚨 超醒目 warning block**:生产环境必须保持 `MULTICA_DEV_VERIFICATION_CODE` 为空;固定测试验证码只用于非 production 私有测试
- Email + verification code 登录流程(依赖 Resend)
- Google OAuth 配置步骤(创建 OAuth client → redirect URI → 填 env)
- **Signup 白名单三层优先级决策树**:
@@ -737,9 +737,9 @@ multica issue assign --agent
- 典型场景:开放给公司域 / 限定几个邮箱 / 完全关闭 signup
- 和邀请的关系(signup 关了也能通过邀请加人)
- **不写**: JWT 实现、token 类型(§8.2 讲)
-- **写前要验证**: APP_ENV 判断条件;OAuth 流程最新;Signup 优先级
+- **写前要验证**: 固定测试验证码的 env 条件;OAuth 流程最新;Signup 优先级
- **⚠️ 动笔前必读**:
- - ⚠️⚠️ **888888 陷阱必须最醒目**(红色 warning block),这是 self-host 最大坑
+ - ⚠️⚠️ **固定测试验证码风险必须最醒目**(红色 warning block),这是 self-host 最大坑
- OAuth 给外部步骤截图,别假设读者懂 GCP Console
- 决策树建议用 Mermaid 图
- **Owner**: –
@@ -754,7 +754,7 @@ multica issue assign --agent
- 任务一直 queued(runtime offline / max_concurrent 满 / agent 配错)
- WebSocket 连不上(cookie / CORS / proxy)
- Email 没收到(Resend 未配置 → 看 stderr)
- - 验证码收到是 888888 但不工作(APP_ENV 检查)
+ - 固定测试验证码不工作(APP_ENV / MULTICA_DEV_VERIFICATION_CODE 检查)
- Port 冲突
- 日志位置:daemon / server / browser console
- **不写**: 深度 bug report(去 GitHub issue)
diff --git a/docs/docs-rewrite-plan.md b/docs/docs-rewrite-plan.md
index d1dd6ed9a3..ecc07ab1a0 100644
--- a/docs/docs-rewrite-plan.md
+++ b/docs/docs-rewrite-plan.md
@@ -118,7 +118,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
| Overview | 决策树(哪种部署模式适合你) |
| Docker Compose deployment | `make selfhost` vs `make selfhost-build` |
| Environment variables reference | 完整 env 表 |
-| Authentication setup | **🚨 `APP_ENV != "production"` 会让 verification code 固定为 `888888`** —— 生产必须设置 `APP_ENV=production`;Google OAuth 配置;signup 白名单 |
+| Authentication setup | **🚨 固定测试验证码必须显式设置 `MULTICA_DEV_VERIFICATION_CODE`,生产保持为空**;Google OAuth 配置;signup 白名单 |
| Storage | S3 / CloudFront / 本地磁盘 |
| Email | Resend 配置;**没配会落到 stderr** |
| Upgrading | 版本升级 + migration 策略 |
@@ -145,7 +145,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
| 5 | Webhook autopilot trigger 字段建了但没接路由——第一版不文档化 | Autopilots |
| 6 | custom_env merge 是覆盖而非合并——不能用 custom_env"取消设置"系统 env | Agents |
| 7 | 旧 assignee 取消分配后不会被取消订阅 | Subscriptions |
-| 8 | `APP_ENV != "production"` 时 verification code 恒为 `888888` | Self-Hosting → Auth |
+| 8 | 固定本地测试验证码默认关闭;`MULTICA_DEV_VERIFICATION_CODE` 仅用于非 production 私有测试 | Self-Hosting → Auth |
| 9 | Signup 白名单优先级:ALLOWED_EMAILS > ALLOWED_EMAIL_DOMAINS > ALLOW_SIGNUP | Self-Hosting → Auth |
| 10 | One daemon ↔ many runtimes;one runtime ↔ ONE provider;同 daemon_id 重启复用旧 runtime 行 | Runtimes / Daemon |
| 11 | Inbox 10 种类型,mention dedup 只在单 event 内生效 | Inbox |
@@ -159,7 +159,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
|---|---|
| Mermaid diagram | 架构图 / task 生命周期 / trigger 流向 / autopilot 调度链 |
| Tabs | Cloud / Self-Host / Desktop 并列;CLI / UI 并列 |
-| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的 888888** |
+| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的固定测试验证码** |
| Code Tabs | API 调用多语言(Shell / Node / Go) |
| Video / GIF | "Create your first agent"、"Follow an agent working" |
| DeploymentPicker(定制)| 交互式决策树:回答 3 个问题 → 推荐部署路径 |
diff --git a/docs/product-overview.md b/docs/product-overview.md
index 2d9b9c2324..8c85f0daff 100644
--- a/docs/product-overview.md
+++ b/docs/product-overview.md
@@ -82,7 +82,7 @@ Multica 做的事:
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
-Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent
+Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
@@ -244,7 +244,7 @@ Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于
#### 配置字段
- **基本信息**:名字、描述、头像(自动生成)
-- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个
+- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro 中的哪一个
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
- **Instructions 说明书**:agent 的系统提示词("你是一个资深工程师...")
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`CLAUDE_CODE_USE_BEDROCK`)
@@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent
`multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它:
-1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`)
+1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`)
2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime)
3. 每 3 秒 **轮询** 一次 server,有任务就认领
4. 每 15 秒 **心跳**(keepalive),报告自己还活着
diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts
index dea6971b45..a54af46ff6 100644
--- a/packages/core/api/client.ts
+++ b/packages/core/api/client.ts
@@ -55,6 +55,11 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
+ Label,
+ CreateLabelRequest,
+ UpdateLabelRequest,
+ ListLabelsResponse,
+ IssueLabelsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
@@ -1030,6 +1035,50 @@ export class ApiClient {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
+ // Labels
+ async listLabels(): Promise {
+ return this.fetch(`/api/labels`);
+ }
+
+ async getLabel(id: string): Promise {
+ return this.fetch(`/api/labels/${id}`);
+ }
+
+ async createLabel(data: CreateLabelRequest): Promise {
+ return this.fetch(`/api/labels`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ }
+
+ async updateLabel(id: string, data: UpdateLabelRequest): Promise {
+ return this.fetch(`/api/labels/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+ }
+
+ async deleteLabel(id: string): Promise {
+ await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
+ }
+
+ async listLabelsForIssue(issueId: string): Promise {
+ return this.fetch(`/api/issues/${issueId}/labels`);
+ }
+
+ async attachLabel(issueId: string, labelId: string): Promise {
+ return this.fetch(`/api/issues/${issueId}/labels`, {
+ method: "POST",
+ body: JSON.stringify({ label_id: labelId }),
+ });
+ }
+
+ async detachLabel(issueId: string, labelId: string): Promise {
+ return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
+ method: "DELETE",
+ });
+ }
+
// Pins
async listPins(): Promise {
return this.fetch("/api/pins");
diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts
index 476caac776..9fd534a27f 100644
--- a/packages/core/chat/index.ts
+++ b/packages/core/chat/index.ts
@@ -1,4 +1,4 @@
-export { createChatStore, DRAFT_NEW_SESSION } from "./store";
+export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";
diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts
index 52d9796273..22dde41162 100644
--- a/packages/core/chat/store.ts
+++ b/packages/core/chat/store.ts
@@ -11,6 +11,9 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFTS_KEY = "multica:chat:drafts";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
+const CHAT_WIDTH_KEY = "multica:chat:width";
+const CHAT_HEIGHT_KEY = "multica:chat:height";
+const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/** Focus mode is a personal preference — global across workspaces/sessions. */
const FOCUS_MODE_KEY = "multica:chat:focusMode";
@@ -38,6 +41,11 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record;
/**
@@ -78,20 +88,22 @@ export interface ChatState {
* the preference survives workspace switches and reloads.
*/
focusMode: boolean;
- /**
- * Last location where a context anchor could be derived (issue/project/inbox).
- * Updated globally by useAnchorTracker; used as a fallback for the Chat page
- * which is its own route and therefore has no anchor of its own.
- * Not persisted — resets per session; focus mode itself persists.
- */
- lastAnchorLocation: { pathname: string; search: string } | null;
+ /** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
+ chatWidth: number;
+ chatHeight: number;
+ isExpanded: boolean;
+ setOpen: (open: boolean) => void;
+ toggle: () => void;
setActiveSession: (id: string | null) => void;
setSelectedAgentId: (id: string) => void;
+ setShowHistory: (show: boolean) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
setFocusMode: (on: boolean) => void;
- setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void;
+ /** Persist raw size and auto-exit expanded mode. */
+ setChatSize: (width: number, height: number) => void;
+ setExpanded: (expanded: boolean) => void;
}
export interface ChatStoreOptions {
@@ -107,12 +119,24 @@ export function createChatStore(options: ChatStoreOptions) {
};
const store = create((set, get) => ({
+ isOpen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
+ showHistory: false,
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
- lastAnchorLocation: null,
- setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }),
+ chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
+ chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
+ isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
+ setOpen: (open) => {
+ logger.debug("setOpen", { from: get().isOpen, to: open });
+ set({ isOpen: open });
+ },
+ toggle: () => {
+ const next = !get().isOpen;
+ logger.debug("toggle", { to: next });
+ set({ isOpen: next });
+ },
setActiveSession: (id) => {
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
if (id) {
@@ -127,6 +151,10 @@ export function createChatStore(options: ChatStoreOptions) {
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
+ setShowHistory: (show) => {
+ logger.debug("setShowHistory", { to: show });
+ set({ showHistory: show });
+ },
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });
@@ -152,6 +180,23 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
+ setChatSize: (w, h) => {
+ logger.debug("setChatSize", { w, h });
+ storage.setItem(CHAT_WIDTH_KEY, String(w));
+ storage.setItem(CHAT_HEIGHT_KEY, String(h));
+ // Dragging = user chose a manual size → exit expanded mode
+ storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
+ set({ chatWidth: w, chatHeight: h, isExpanded: false });
+ },
+ setExpanded: (expanded) => {
+ logger.info("setExpanded", { to: expanded });
+ if (expanded) {
+ storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
+ } else {
+ storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
+ }
+ set({ isExpanded: expanded });
+ },
}));
registerForWorkspaceRehydration(() => {
@@ -165,15 +210,10 @@ export function createChatStore(options: ChatStoreOptions) {
nextAgent,
draftCount: Object.keys(nextDrafts).length,
});
- // lastAnchorLocation is not persisted — reset it here so a pathname
- // captured in the previous workspace can't be reused against the new
- // workspace's wsId (would trigger a cross-workspace issue/project fetch
- // and silently leak context into chat messages).
store.setState({
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
- lastAnchorLocation: null,
});
});
diff --git a/packages/core/inbox/queries.ts b/packages/core/inbox/queries.ts
index f5971a53c0..23a3f1be79 100644
--- a/packages/core/inbox/queries.ts
+++ b/packages/core/inbox/queries.ts
@@ -1,4 +1,4 @@
-import { queryOptions } from "@tanstack/react-query";
+import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
@@ -14,6 +14,22 @@ export function inboxListOptions(wsId: string) {
});
}
+/**
+ * Unread inbox count for the given workspace, aligned with what the inbox
+ * list UI renders: archived items excluded, then deduplicated by issue so a
+ * single issue with three unread notifications counts once.
+ */
+export function useInboxUnreadCount(wsId: string | null | undefined): number {
+ const { data } = useQuery({
+ queryKey: inboxKeys.list(wsId ?? ""),
+ queryFn: () => api.listInbox(),
+ enabled: !!wsId,
+ select: (items: InboxItem[]) =>
+ deduplicateInboxItems(items).filter((i) => !i.read).length,
+ });
+ return data ?? 0;
+}
+
/**
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
* Exported for consumers to use in useMemo — not in queryOptions select
diff --git a/packages/core/issues/config/status.ts b/packages/core/issues/config/status.ts
index ed7ab7c5a5..3d32e6808f 100644
--- a/packages/core/issues/config/status.ts
+++ b/packages/core/issues/config/status.ts
@@ -37,16 +37,14 @@ export const STATUS_CONFIG: Record<
iconColor: string;
hoverBg: string;
dividerColor: string;
- badgeBg: string;
- badgeText: string;
columnBg: string;
}
> = {
- backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
- todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
- in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", badgeBg: "bg-warning", badgeText: "text-white", columnBg: "bg-warning/5" },
- in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", badgeBg: "bg-success", badgeText: "text-white", columnBg: "bg-success/5" },
- done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", badgeBg: "bg-info", badgeText: "text-white", columnBg: "bg-info/5" },
- blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", badgeBg: "bg-destructive", badgeText: "text-white", columnBg: "bg-destructive/5" },
- cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" },
+ backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" },
+ todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" },
+ in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", columnBg: "bg-warning/5" },
+ in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", columnBg: "bg-success/5" },
+ done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", columnBg: "bg-info/5" },
+ blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", columnBg: "bg-destructive/5" },
+ cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" },
};
diff --git a/packages/core/issues/stores/draft-store.test.ts b/packages/core/issues/stores/draft-store.test.ts
new file mode 100644
index 0000000000..3cecb6ea35
--- /dev/null
+++ b/packages/core/issues/stores/draft-store.test.ts
@@ -0,0 +1,60 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { useIssueDraftStore } from "./draft-store";
+
+const RESET_STATE = {
+ draft: {
+ title: "",
+ description: "",
+ status: "todo" as const,
+ priority: "none" as const,
+ assigneeType: undefined,
+ assigneeId: undefined,
+ dueDate: null,
+ },
+ lastAssigneeType: undefined,
+ lastAssigneeId: undefined,
+};
+
+describe("issue draft store — last assignee", () => {
+ beforeEach(() => {
+ useIssueDraftStore.setState(RESET_STATE);
+ });
+
+ it("clearDraft prefills the next draft with the remembered assignee", () => {
+ const { setDraft, setLastAssignee, clearDraft } =
+ useIssueDraftStore.getState();
+
+ setDraft({ title: "first", assigneeType: "member", assigneeId: "alice" });
+ setLastAssignee("member", "alice");
+ clearDraft();
+
+ const { draft } = useIssueDraftStore.getState();
+ expect(draft.title).toBe("");
+ expect(draft.assigneeType).toBe("member");
+ expect(draft.assigneeId).toBe("alice");
+ });
+
+ it("clearDraft yields an empty assignee when none has ever been remembered", () => {
+ const { setDraft, clearDraft } = useIssueDraftStore.getState();
+
+ setDraft({ title: "first" });
+ clearDraft();
+
+ const { draft } = useIssueDraftStore.getState();
+ expect(draft.assigneeType).toBeUndefined();
+ expect(draft.assigneeId).toBeUndefined();
+ });
+
+ it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
+ const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
+
+ setLastAssignee("member", "alice");
+ clearDraft();
+ expect(useIssueDraftStore.getState().draft.assigneeId).toBe("alice");
+
+ setLastAssignee(undefined, undefined);
+ clearDraft();
+ expect(useIssueDraftStore.getState().draft.assigneeId).toBeUndefined();
+ expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
+ });
+});
diff --git a/packages/core/issues/stores/draft-store.ts b/packages/core/issues/stores/draft-store.ts
index 7ca50530a1..3e168691c2 100644
--- a/packages/core/issues/stores/draft-store.ts
+++ b/packages/core/issues/stores/draft-store.ts
@@ -26,8 +26,14 @@ const EMPTY_DRAFT: IssueDraft = {
interface IssueDraftStore {
draft: IssueDraft;
+ // Last assignee picked at submit time. Persisted across drafts so the
+ // create-issue modal can prefill the picker with the user's most recent
+ // choice instead of always opening with no assignee.
+ lastAssigneeType?: IssueAssigneeType;
+ lastAssigneeId?: string;
setDraft: (patch: Partial) => void;
clearDraft: () => void;
+ setLastAssignee: (type?: IssueAssigneeType, id?: string) => void;
hasDraft: () => boolean;
}
@@ -35,9 +41,20 @@ export const useIssueDraftStore = create()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
+ lastAssigneeType: undefined,
+ lastAssigneeId: undefined,
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
- clearDraft: () => set({ draft: { ...EMPTY_DRAFT } }),
+ clearDraft: () =>
+ set((s) => ({
+ draft: {
+ ...EMPTY_DRAFT,
+ assigneeType: s.lastAssigneeType,
+ assigneeId: s.lastAssigneeId,
+ },
+ })),
+ setLastAssignee: (type, id) =>
+ set({ lastAssigneeType: type, lastAssigneeId: id }),
hasDraft: () => {
const { draft } = get();
return !!(draft.title || draft.description);
diff --git a/packages/core/issues/stores/my-issues-view-store.ts b/packages/core/issues/stores/my-issues-view-store.ts
index 9ffb373c1f..766f41388a 100644
--- a/packages/core/issues/stores/my-issues-view-store.ts
+++ b/packages/core/issues/stores/my-issues-view-store.ts
@@ -6,6 +6,7 @@ import {
type IssueViewState,
viewStoreSlice,
viewStorePersistOptions,
+ mergeViewStatePersisted,
} from "./view-store";
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
@@ -32,6 +33,11 @@ const _myIssuesViewStore = createStore()(
...basePersist.partialize(state),
scope: state.scope,
}),
+ // Reuse the same deep-merge as the base view store so newly added
+ // cardProperties toggles inherit defaults for existing users. Without
+ // this, the my-issues page renders no labels because the persisted
+ // snapshot predates the `labels` key and shallow-merge wins.
+ merge: mergeViewStatePersisted,
},
),
);
diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts
index e4a10c90aa..e61b8f15e3 100644
--- a/packages/core/issues/stores/view-store.ts
+++ b/packages/core/issues/stores/view-store.ts
@@ -20,6 +20,7 @@ export interface CardProperties {
dueDate: boolean;
project: boolean;
childProgress: boolean;
+ labels: boolean;
}
export interface ActorFilterValue {
@@ -41,6 +42,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
+ { key: "labels", label: "Labels" },
{ key: "childProgress", label: "Sub-issue progress" },
];
@@ -53,6 +55,7 @@ export interface IssueViewState {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
+ labelFilters: string[];
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
@@ -65,6 +68,7 @@ export interface IssueViewState {
toggleCreatorFilter: (value: ActorFilterValue) => void;
toggleProjectFilter: (projectId: string) => void;
toggleNoProject: () => void;
+ toggleLabelFilter: (labelId: string) => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
@@ -83,6 +87,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
+ labelFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -92,6 +97,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue
dueDate: true,
project: true,
childProgress: true,
+ labels: true,
},
listCollapsedStatuses: [],
@@ -144,6 +150,12 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue
})),
toggleNoProject: () =>
set((state) => ({ includeNoProject: !state.includeNoProject })),
+ toggleLabelFilter: (labelId) =>
+ set((state) => ({
+ labelFilters: state.labelFilters.includes(labelId)
+ ? state.labelFilters.filter((id) => id !== labelId)
+ : [...state.labelFilters, labelId],
+ })),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
@@ -169,6 +181,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
+ labelFilters: [],
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
@@ -199,13 +212,40 @@ export const viewStorePersistOptions = (name: string) => ({
creatorFilters: state.creatorFilters,
projectFilters: state.projectFilters,
includeNoProject: state.includeNoProject,
+ labelFilters: state.labelFilters,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
}),
+ // Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
+ // saved before a new toggle was introduced wins entirely and the new key is
+ // missing — the dropdown switch then reads `undefined` and renders unchecked
+ // even though defaults treat it as on. Deep-merge `cardProperties` so newly
+ // added toggles inherit their default value for existing users.
+ merge: mergeViewStatePersisted,
});
+/**
+ * Reusable persist `merge` for view-state stores. Generic over T so the same
+ * deep-merge for `cardProperties` works for both the issues view store and
+ * the my-issues view store (which extends IssueViewState).
+ */
+export function mergeViewStatePersisted(
+ persisted: unknown,
+ current: T,
+): T {
+ const p = (persisted ?? {}) as Partial;
+ return {
+ ...current,
+ ...p,
+ cardProperties: {
+ ...current.cardProperties,
+ ...(p.cardProperties ?? {}),
+ },
+ };
+}
+
/** Factory: creates a vanilla StoreApi for use with React Context. */
export function createIssueViewStore(persistKey: string): StoreApi {
const store = createStore()(
diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts
index 6f55544d7d..299019fb11 100644
--- a/packages/core/issues/ws-updaters.ts
+++ b/packages/core/issues/ws-updaters.ts
@@ -6,7 +6,7 @@ import {
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
-import type { Issue } from "../types";
+import type { Issue, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -72,6 +72,26 @@ export function onIssueUpdated(
}
}
+/**
+ * Patch an issue's `labels` field in-place across the list cache, my-issues
+ * caches, and the detail cache. Triggered by the `issue_labels:changed` WS
+ * event after attach/detach so list/board chips update without a refetch.
+ */
+export function onIssueLabelsChanged(
+ qc: QueryClient,
+ wsId: string,
+ issueId: string,
+ labels: Label[],
+) {
+ qc.setQueryData(issueKeys.list(wsId), (old) =>
+ old ? patchIssueInBuckets(old, issueId, { labels }) : old,
+ );
+ qc.setQueryData(issueKeys.detail(wsId, issueId), (old) =>
+ old ? { ...old, labels } : old,
+ );
+ qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
+}
+
export function onIssueDeleted(
qc: QueryClient,
wsId: string,
diff --git a/packages/core/labels/index.ts b/packages/core/labels/index.ts
new file mode 100644
index 0000000000..6a8890b663
--- /dev/null
+++ b/packages/core/labels/index.ts
@@ -0,0 +1,8 @@
+export { labelKeys, labelListOptions, issueLabelsOptions } from "./queries";
+export {
+ useCreateLabel,
+ useUpdateLabel,
+ useDeleteLabel,
+ useAttachLabel,
+ useDetachLabel,
+} from "./mutations";
diff --git a/packages/core/labels/mutations.ts b/packages/core/labels/mutations.ts
new file mode 100644
index 0000000000..23a2654d39
--- /dev/null
+++ b/packages/core/labels/mutations.ts
@@ -0,0 +1,171 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../api";
+import { labelKeys } from "./queries";
+import { useWorkspaceId } from "../hooks";
+import { issueKeys } from "../issues/queries";
+import { onIssueLabelsChanged } from "../issues/ws-updaters";
+import type {
+ Label,
+ CreateLabelRequest,
+ UpdateLabelRequest,
+ ListLabelsResponse,
+ IssueLabelsResponse,
+} from "../types";
+
+export function useCreateLabel() {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceId();
+ return useMutation({
+ mutationFn: (data: CreateLabelRequest) => api.createLabel(data),
+ onSuccess: (label) => {
+ qc.setQueryData(labelKeys.list(wsId), (old) =>
+ old && !old.labels.some((l) => l.id === label.id)
+ ? { ...old, labels: [...old.labels, label], total: old.total + 1 }
+ : old,
+ );
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: labelKeys.list(wsId) });
+ },
+ });
+}
+
+/**
+ * Optimistic rename/recolor. Matches the useUpdateProject pattern: apply the
+ * change locally, snapshot for rollback, invalidate on settle. Without this
+ * the UI freezes for the round-trip on every edit.
+ */
+export function useUpdateLabel() {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceId();
+ return useMutation({
+ mutationFn: ({ id, ...data }: { id: string } & UpdateLabelRequest) =>
+ api.updateLabel(id, data),
+ onMutate: async ({ id, ...data }) => {
+ await qc.cancelQueries({ queryKey: labelKeys.list(wsId) });
+ const prevList = qc.getQueryData(labelKeys.list(wsId));
+ qc.setQueryData(labelKeys.list(wsId), (old) =>
+ old
+ ? {
+ ...old,
+ labels: old.labels.map((l) => (l.id === id ? { ...l, ...data } : l)),
+ }
+ : old,
+ );
+ return { prevList, id };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prevList) qc.setQueryData(labelKeys.list(wsId), ctx.prevList);
+ },
+ onSettled: () => {
+ // Invalidate the entire labels scope so any byIssue cache holding a
+ // stale copy of this label is refetched. The list cache is the source
+ // of truth; byIssue views will re-render with the fresh data.
+ qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
+ // Issues now embed labels (denormalized snapshot), so a rename/recolor
+ // also has to refresh the issues caches that hold those snapshots.
+ qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
+ },
+ });
+}
+
+export function useDeleteLabel() {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceId();
+ return useMutation({
+ mutationFn: (id: string) => api.deleteLabel(id),
+ onMutate: async (id) => {
+ await qc.cancelQueries({ queryKey: labelKeys.list(wsId) });
+ const prev = qc.getQueryData(labelKeys.list(wsId));
+ qc.setQueryData(labelKeys.list(wsId), (old) =>
+ old
+ ? { ...old, labels: old.labels.filter((l) => l.id !== id), total: old.total - 1 }
+ : old,
+ );
+ return { prev };
+ },
+ onError: (_err, _id, ctx) => {
+ if (ctx?.prev) qc.setQueryData(labelKeys.list(wsId), ctx.prev);
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
+ // A deleted label still lives in cached issue.labels arrays until we
+ // refetch — invalidate so list/board chips drop the orphan.
+ qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
+ },
+ });
+}
+
+export function useAttachLabel(issueId: string) {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceId();
+ return useMutation({
+ mutationFn: (labelId: string) => api.attachLabel(issueId, labelId),
+ onMutate: async (labelId) => {
+ await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
+ const prev = qc.getQueryData(labelKeys.byIssue(wsId, issueId));
+ // Only patch when we already know the current label set — otherwise
+ // appending `[label]` to an empty array would wipe denormalized
+ // labels in issue list/detail caches and rollback couldn't restore
+ // them. If byIssue isn't cached yet (user clicked before the picker
+ // fetched), skip the optimistic patch and rely on onSettled refetch.
+ if (!prev) return { prev };
+ if (prev.labels.some((l) => l.id === labelId)) return { prev };
+ const list = qc.getQueryData(labelKeys.list(wsId));
+ const label = list?.labels.find((l) => l.id === labelId);
+ if (!label) return { prev };
+ const next: IssueLabelsResponse = { ...prev, labels: [...prev.labels, label] };
+ qc.setQueryData(labelKeys.byIssue(wsId, issueId), next);
+ onIssueLabelsChanged(qc, wsId, issueId, next.labels);
+ return { prev };
+ },
+ onError: (_err, _id, ctx) => {
+ if (ctx?.prev) {
+ qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
+ onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
+ }
+ },
+ onSuccess: (data: IssueLabelsResponse) => {
+ // Backend may return an empty object when the post-mutation read fails
+ // (it logs a warning and skips the broadcast). Only apply the list
+ // when the backend gave us one — otherwise the optimistic patch from
+ // onMutate stands until onSettled's invalidation refetches.
+ if (data && Array.isArray(data.labels)) {
+ qc.setQueryData(labelKeys.byIssue(wsId, issueId), data);
+ onIssueLabelsChanged(qc, wsId, issueId, data.labels);
+ }
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
+ },
+ });
+}
+
+export function useDetachLabel(issueId: string) {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceId();
+ return useMutation({
+ mutationFn: (labelId: string) => api.detachLabel(issueId, labelId),
+ onMutate: async (labelId) => {
+ await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
+ const prev = qc.getQueryData(labelKeys.byIssue(wsId, issueId));
+ const next = prev
+ ? { ...prev, labels: prev.labels.filter((l: Label) => l.id !== labelId) }
+ : undefined;
+ if (next) {
+ qc.setQueryData(labelKeys.byIssue(wsId, issueId), next);
+ onIssueLabelsChanged(qc, wsId, issueId, next.labels);
+ }
+ return { prev };
+ },
+ onError: (_err, _id, ctx) => {
+ if (ctx?.prev) {
+ qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
+ onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
+ }
+ },
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
+ },
+ });
+}
diff --git a/packages/core/labels/queries.ts b/packages/core/labels/queries.ts
new file mode 100644
index 0000000000..f2a61b1378
--- /dev/null
+++ b/packages/core/labels/queries.ts
@@ -0,0 +1,28 @@
+import { queryOptions } from "@tanstack/react-query";
+import { api } from "../api";
+
+export const labelKeys = {
+ all: (wsId: string) => ["labels", wsId] as const,
+ list: (wsId: string) => [...labelKeys.all(wsId), "list"] as const,
+ detail: (wsId: string, id: string) =>
+ [...labelKeys.all(wsId), "detail", id] as const,
+ byIssue: (wsId: string, issueId: string) =>
+ [...labelKeys.all(wsId), "issue", issueId] as const,
+};
+
+export function labelListOptions(wsId: string) {
+ return queryOptions({
+ queryKey: labelKeys.list(wsId),
+ queryFn: () => api.listLabels(),
+ select: (data) => data.labels,
+ });
+}
+
+export function issueLabelsOptions(wsId: string, issueId: string) {
+ return queryOptions({
+ queryKey: labelKeys.byIssue(wsId, issueId),
+ queryFn: () => api.listLabelsForIssue(issueId),
+ select: (data) => data.labels,
+ enabled: Boolean(issueId),
+ });
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index 4b182c355b..cbc2e1fdf3 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -50,6 +50,9 @@
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
+ "./labels": "./labels/index.ts",
+ "./labels/queries": "./labels/queries.ts",
+ "./labels/mutations": "./labels/mutations.ts",
"./autopilots": "./autopilots/index.ts",
"./autopilots/queries": "./autopilots/queries.ts",
"./autopilots/mutations": "./autopilots/mutations.ts",
diff --git a/packages/core/paths/consistency.test.ts b/packages/core/paths/consistency.test.ts
index 838c5f2c9f..3b4bc890a6 100644
--- a/packages/core/paths/consistency.test.ts
+++ b/packages/core/paths/consistency.test.ts
@@ -22,7 +22,6 @@ describe("paths.workspace() shape", () => {
"autopilots",
"agents",
"inbox",
- "chat",
"myIssues",
"runtimes",
"skills",
@@ -41,7 +40,6 @@ describe("paths.workspace() shape", () => {
["autopilots", "autopilots"],
["agents", "agents"],
["inbox", "inbox"],
- ["chat", "chat"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],
diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts
index a412b2c09c..27b0b3cd37 100644
--- a/packages/core/paths/paths.ts
+++ b/packages/core/paths/paths.ts
@@ -27,7 +27,6 @@ function workspaceScoped(slug: string) {
agents: () => `${ws}/agents`,
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
inbox: () => `${ws}/inbox`,
- chat: () => `${ws}/chat`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,
runtimeDetail: (id: string) => `${ws}/runtimes/${encode(id)}`,
diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts
index fd747cab53..52b21fd24f 100644
--- a/packages/core/realtime/use-realtime-sync.ts
+++ b/packages/core/realtime/use-realtime-sync.ts
@@ -19,6 +19,7 @@ import {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
+ onIssueLabelsChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
@@ -32,6 +33,7 @@ import type {
IssueUpdatedPayload,
IssueCreatedPayload,
IssueDeletedPayload,
+ IssueLabelsChangedPayload,
InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
@@ -118,6 +120,17 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
+ label: () => {
+ // label:created/updated/deleted — also refresh issues, since each
+ // issue carries a denormalized snapshot of its labels (rename/recolor
+ // /delete on a label needs to flush the chips on every issue showing
+ // it).
+ const wsId = getCurrentWsId();
+ if (wsId) {
+ qc.invalidateQueries({ queryKey: ["labels", wsId] });
+ qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
+ }
+ },
pin: () => {
const wsId = getCurrentWsId();
const userId = authStore.getState().user?.id;
@@ -167,7 +180,7 @@ export function useRealtimeSync(
// Event types handled by specific handlers below -- skip generic refresh
const specificEvents = new Set([
- "issue:updated", "issue:created", "issue:deleted", "inbox:new",
+ "issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"activity:created",
"reaction:added", "reaction:removed",
@@ -229,11 +242,53 @@ export function useRealtimeSync(
}
});
+ const unsubIssueLabelsChanged = ws.on("issue_labels:changed", (p) => {
+ const { issue_id, labels } = p as IssueLabelsChangedPayload;
+ if (!issue_id) return;
+ const wsId = getCurrentWsId();
+ if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
+ });
+
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (!item) return;
const wsId = getCurrentWsId();
if (wsId) onInboxNew(qc, wsId, item);
+ // Fire a native OS notification only when the app isn't focused. When
+ // the user is already looking at Multica, the inbox sidebar's unread
+ // styling is enough — no need to interrupt with a banner. `desktopAPI`
+ // is injected by the preload script; its absence (web app) skips silently.
+ if (typeof document !== "undefined" && document.hasFocus()) return;
+ // Capture the source workspace slug at emit time. The user may switch
+ // workspaces before clicking the banner (macOS Notification Center
+ // holds banners), so routing must not read "current slug" at click
+ // time — otherwise notifications from workspace A click through to
+ // workspace B's inbox and 404.
+ const slug = getCurrentSlug();
+ if (!slug) return;
+ const desktopAPI = (
+ window as unknown as {
+ desktopAPI?: {
+ showNotification?: (payload: {
+ slug: string;
+ itemId: string;
+ issueKey: string;
+ title: string;
+ body: string;
+ }) => void;
+ };
+ }
+ ).desktopAPI;
+ // `issueKey` matches the inbox page's URL selector (issue id when the
+ // item is attached to an issue, otherwise the inbox item id). `itemId`
+ // is the inbox row's own id, needed to fire markInboxRead on click.
+ desktopAPI?.showNotification?.({
+ slug,
+ itemId: item.id,
+ issueKey: item.issue_id ?? item.id,
+ title: item.title,
+ body: item.body ?? "",
+ });
});
// --- Timeline event handlers (global fallback) ---
@@ -391,7 +446,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
});
- // --- Chat / task events (global, survives chat page unmount) ---
+ // --- Chat / task events (global, survives ChatWindow unmount) ---
//
// Single source of truth: the Query cache. No Zustand writes here — the
// earlier mirror caused a race where the cache and store disagreed
@@ -493,6 +548,7 @@ export function useRealtimeSync(
unsubIssueUpdated();
unsubIssueCreated();
unsubIssueDeleted();
+ unsubIssueLabelsChanged();
unsubInboxNew();
unsubCommentCreated();
unsubCommentUpdated();
diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts
index a09954a3b0..d4660c75b4 100644
--- a/packages/core/types/events.ts
+++ b/packages/core/types/events.ts
@@ -5,6 +5,7 @@ import type { Comment, Reaction } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
import type { Project } from "./project";
+import type { Label } from "./label";
// WebSocket event types (matching Go server protocol/events.go)
export type WSEventType =
@@ -52,6 +53,10 @@ export type WSEventType =
| "project:created"
| "project:updated"
| "project:deleted"
+ | "label:created"
+ | "label:updated"
+ | "label:deleted"
+ | "issue_labels:changed"
| "pin:created"
| "pin:deleted"
| "pin:reordered"
@@ -78,6 +83,11 @@ export interface IssueDeletedPayload {
issue_id: string;
}
+export interface IssueLabelsChangedPayload {
+ issue_id: string;
+ labels: Label[];
+}
+
export interface AgentStatusPayload {
agent: Agent;
}
diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts
index 1444a00807..1bc447015e 100644
--- a/packages/core/types/index.ts
+++ b/packages/core/types/index.ts
@@ -39,6 +39,7 @@ export type {
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
+export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
export type { IssueSubscriber } from "./subscriber";
export type * from "./events";
diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts
index 5625274d94..ba30d69be9 100644
--- a/packages/core/types/issue.ts
+++ b/packages/core/types/issue.ts
@@ -1,3 +1,5 @@
+import type { Label } from "./label";
+
export type IssueStatus =
| "backlog"
| "todo"
@@ -38,6 +40,7 @@ export interface Issue {
position: number;
due_date: string | null;
reactions?: IssueReaction[];
+ labels?: Label[];
created_at: string;
updated_at: string;
}
diff --git a/packages/core/types/label.ts b/packages/core/types/label.ts
new file mode 100644
index 0000000000..c3bd5ed0e7
--- /dev/null
+++ b/packages/core/types/label.ts
@@ -0,0 +1,35 @@
+/**
+ * Issue labels — workspace-scoped, applied as many-to-many to issues.
+ *
+ * Labels are lightweight metadata (name + color) distinct from projects:
+ * projects group related work, labels are cross-cutting tags (bug, feature,
+ * performance, …). Colors are normalized to lowercase `#RRGGBB`.
+ */
+export interface Label {
+ id: string;
+ workspace_id: string;
+ name: string;
+ /** Normalized lowercase hex color, e.g. `#3b82f6`. */
+ color: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateLabelRequest {
+ name: string;
+ color: string;
+}
+
+export interface UpdateLabelRequest {
+ name?: string;
+ color?: string;
+}
+
+export interface ListLabelsResponse {
+ labels: Label[];
+ total: number;
+}
+
+export interface IssueLabelsResponse {
+ labels: Label[];
+}
diff --git a/packages/ui/markdown/Markdown.tsx b/packages/ui/markdown/Markdown.tsx
index 12f44e275e..b3d8e9a9a7 100644
--- a/packages/ui/markdown/Markdown.tsx
+++ b/packages/ui/markdown/Markdown.tsx
@@ -3,6 +3,7 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
+import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { FileText, Download } from 'lucide-react'
@@ -405,7 +406,7 @@ export function Markdown({
return (
pos >= r.start && pos < r.end)
}
+function isEscaped(text: string, index: number): boolean {
+ let slashCount = 0
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
+ slashCount++
+ }
+ return slashCount % 2 === 1
+}
+
+function findMatchingBracket(text: string, openIndex: number): number {
+ let depth = 0
+
+ for (let i = openIndex; i < text.length; i++) {
+ if (isEscaped(text, i)) continue
+
+ const char = text[i]
+ if (char === '[') {
+ depth++
+ } else if (char === ']') {
+ depth--
+ if (depth === 0) return i
+ }
+ }
+
+ return -1
+}
+
+function findInlineLinkEnd(text: string, openParenIndex: number): number {
+ let depth = 0
+
+ for (let i = openParenIndex; i < text.length; i++) {
+ if (isEscaped(text, i)) continue
+
+ const char = text[i]
+ if (char === '(') {
+ depth++
+ } else if (char === ')') {
+ depth--
+ if (depth === 0) return i + 1
+ }
+ }
+
+ return -1
+}
+
+/**
+ * Find existing markdown link/image spans so auto-linkification does not create
+ * nested links inside their labels or destinations.
+ */
+function findMarkdownLinkRanges(text: string): CodeRange[] {
+ const ranges: CodeRange[] = []
+
+ for (let i = 0; i < text.length; i++) {
+ if (text[i] !== '[' || isEscaped(text, i)) continue
+ if (ranges.some((r) => i >= r.start && i < r.end)) continue
+
+ const labelEnd = findMatchingBracket(text, i)
+ if (labelEnd === -1) continue
+
+ const start = i > 0 && text[i - 1] === '!' && !isEscaped(text, i - 1) ? i - 1 : i
+ const nextChar = text[labelEnd + 1]
+
+ if (nextChar === '(') {
+ const end = findInlineLinkEnd(text, labelEnd + 1)
+ if (end !== -1) {
+ ranges.push({ start, end })
+ i = end - 1
+ }
+ continue
+ }
+
+ if (nextChar === '[') {
+ const referenceEnd = findMatchingBracket(text, labelEnd + 1)
+ if (referenceEnd !== -1) {
+ ranges.push({ start, end: referenceEnd + 1 })
+ i = referenceEnd
+ }
+ }
+ }
+
+ return ranges
+}
+
/**
* Check if a link at given position is already a markdown link
* Looks for patterns like [text](url) or [text][ref]
@@ -216,6 +298,7 @@ export function preprocessLinks(text: string): string {
}
const codeRanges = findCodeRanges(text)
+ const markdownLinkRanges = findMarkdownLinkRanges(text)
const links = detectLinks(text)
if (links.length === 0) return text
@@ -228,6 +311,9 @@ export function preprocessLinks(text: string): string {
// Skip if inside code block
if (isInsideCode(link.start, codeRanges)) continue
+ // Skip if this match is inside an existing markdown link or image.
+ if (markdownLinkRanges.some((range) => rangesOverlap(link, range))) continue
+
// Skip if already a markdown link
if (isAlreadyLinked(text, link.start, link.end)) continue
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 5be43233c2..6253c38c6b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -39,6 +39,7 @@
"recharts": "3.8.0",
"rehype-katex": "catalog:",
"rehype-raw": "^7.0.0",
+ "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "catalog:",
"shiki": "^3.21.0",
diff --git a/packages/views/chat/components/chat-fab.tsx b/packages/views/chat/components/chat-fab.tsx
new file mode 100644
index 0000000000..797fca6496
--- /dev/null
+++ b/packages/views/chat/components/chat-fab.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { MessageCircle } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { cn } from "@multica/ui/lib/utils";
+import { useChatStore } from "@multica/core/chat";
+import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
+import { useWorkspaceId } from "@multica/core/hooks";
+import { createLogger } from "@multica/core/logger";
+import {
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@multica/ui/components/ui/tooltip";
+
+const logger = createLogger("chat.ui");
+
+export function ChatFab() {
+ const wsId = useWorkspaceId();
+ const isOpen = useChatStore((s) => s.isOpen);
+ const toggle = useChatStore((s) => s.toggle);
+ const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
+ const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
+
+ if (isOpen) return null;
+
+ const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
+ const isRunning = (pending?.tasks ?? []).length > 0;
+
+ const handleClick = () => {
+ logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
+ toggle();
+ };
+
+ // Tooltip text communicates the state that isn't carried by the icon/badge.
+ const tooltip = isRunning
+ ? "Multica is working..."
+ : unreadSessionCount > 0
+ ? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
+ : "Ask Multica";
+
+ return (
+
+
+
+ {unreadSessionCount > 0 && (
+
+ {unreadSessionCount > 9 ? "9+" : unreadSessionCount}
+
+ )}
+
+ {tooltip}
+
+ );
+}
diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx
index 51745c900c..d5f9e5c0c1 100644
--- a/packages/views/chat/components/chat-input.tsx
+++ b/packages/views/chat/components/chat-input.tsx
@@ -81,7 +81,7 @@ export function ChatInput({
return (
-
+
{topSlot}
m.role === "assistant" && m.task_id === pendingTaskId,
);
- const displayMessages: ChatMessage[] = pendingTaskId && !pendingAlreadyPersisted
- ? [...messages, {
- id: `pending-${pendingTaskId}`,
- chat_session_id: messages[messages.length - 1]?.chat_session_id ?? "",
- role: "assistant",
- content: "",
- task_id: pendingTaskId,
- created_at: new Date().toISOString(),
- }]
- : messages;
+
+ // Live timeline for the in-flight task. useRealtimeSync keeps this cache
+ // current via setQueryData on task:message events.
+ const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
+ const { data: liveTaskMessages } = useQuery({
+ ...taskMessagesOptions(pendingTaskId ?? ""),
+ enabled: showLiveTimeline,
+ });
+ const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
+ const hasLive = showLiveTimeline && liveTimeline.length > 0;
return (
@@ -63,14 +60,15 @@ export function ChatMessageList({
* views doesn't jolt the reading width. px-5 is a touch tighter
* than issue-detail's px-8 because the chat window can be narrow. */}
- {displayMessages.map((msg) => (
-
+ {messages.map((msg) => (
+
))}
- {isWaiting && !pendingTaskId && (
+ {hasLive && (
+
+
+
+ )}
+ {isWaiting && !hasLive && !pendingAlreadyPersisted && (
)}
@@ -118,7 +116,7 @@ function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem {
// ─── Message bubbles ─────────────────────────────────────────────────────
-function MessageBubble({ message, isPending }: { message: ChatMessage; isPending?: boolean }) {
+function MessageBubble({ message }: { message: ChatMessage }) {
if (message.role === "user") {
return (
@@ -135,15 +133,13 @@ function MessageBubble({ message, isPending }: { message: ChatMessage; isPending
);
}
- return
;
+ return
;
}
function AssistantMessage({
message,
- isPending,
}: {
message: ChatMessage;
- isPending?: boolean;
}) {
const taskId = message.task_id;
@@ -161,13 +157,11 @@ function AssistantMessage({
{timeline.length > 0 ? (
- ) : message.content ? (
+ ) : (
{message.content}
- ) : isPending ? (
-
- ) : null}
+ )}
);
}
diff --git a/packages/views/chat/components/chat-page.tsx b/packages/views/chat/components/chat-page.tsx
deleted file mode 100644
index e02d1550e7..0000000000
--- a/packages/views/chat/components/chat-page.tsx
+++ /dev/null
@@ -1,508 +0,0 @@
-"use client";
-
-import React, { useCallback, useEffect, useMemo, useRef } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { History, Plus, Bot, ChevronDown, Check } from "lucide-react";
-import { cn } from "@multica/ui/lib/utils";
-import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
-import { Button } from "@multica/ui/components/ui/button";
-import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
-import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@multica/ui/components/ui/dropdown-menu";
-import { useWorkspaceId } from "@multica/core/hooks";
-import { useAuthStore } from "@multica/core/auth";
-import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
-import { canAssignAgent } from "@multica/views/issues/components";
-import { api } from "@multica/core/api";
-import {
- chatSessionsOptions,
- allChatSessionsOptions,
- chatMessagesOptions,
- pendingChatTaskOptions,
- chatKeys,
-} from "@multica/core/chat/queries";
-import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
-import { useChatStore } from "@multica/core/chat";
-import { PageHeader } from "../../layout/page-header";
-import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
-import { ChatInput } from "./chat-input";
-import {
- ContextAnchorButton,
- ContextAnchorCard,
- buildAnchorMarkdown,
- useRouteAnchorCandidate,
-} from "./context-anchor";
-import { createLogger } from "@multica/core/logger";
-import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
-
-const uiLogger = createLogger("chat.ui");
-const apiLogger = createLogger("chat.api");
-
-export function ChatPage() {
- const wsId = useWorkspaceId();
- const activeSessionId = useChatStore((s) => s.activeSessionId);
- const selectedAgentId = useChatStore((s) => s.selectedAgentId);
- const setActiveSession = useChatStore((s) => s.setActiveSession);
- const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
- const user = useAuthStore((s) => s.user);
- const { data: agents = [] } = useQuery(agentListOptions(wsId));
- const { data: members = [] } = useQuery(memberListOptions(wsId));
- const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery(
- chatSessionsOptions(wsId),
- );
- const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
- const { data: rawMessages, isLoading: messagesLoading } = useQuery(
- chatMessagesOptions(activeSessionId ?? ""),
- );
- const messages = activeSessionId ? rawMessages ?? [] : [];
- const showSkeleton = !!activeSessionId && messagesLoading;
-
- const { data: pendingTask } = useQuery(
- pendingChatTaskOptions(activeSessionId ?? ""),
- );
- const pendingTaskId = pendingTask?.task_id ?? null;
-
- const currentSession = activeSessionId
- ? allSessions.find((s) => s.id === activeSessionId)
- : null;
- const isSessionArchived = currentSession?.status === "archived";
-
- const qc = useQueryClient();
- const createSession = useCreateChatSession();
- const markRead = useMarkChatSessionRead();
-
- const currentMember = members.find((m) => m.user_id === user?.id);
- const memberRole = currentMember?.role;
- const availableAgents = agents.filter(
- (a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
- );
- const activeAgent =
- availableAgents.find((a) => a.id === selectedAgentId) ??
- availableAgents[0] ??
- null;
-
- // Restore most recent active session once the session query resolves.
- // The ref is set only AFTER we've seen a successful query — setting it
- // unconditionally on first render would lose the restore whenever the
- // page mounts before the query returns (cold-start / direct navigate).
- const didRestoreRef = useRef(false);
- useEffect(() => {
- if (didRestoreRef.current) return;
- if (!sessionsLoaded) return;
- didRestoreRef.current = true;
- if (activeSessionId) return;
- const latest = sessions.find((s) => s.status === "active");
- if (latest) setActiveSession(latest.id);
- // eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
- }, [sessionsLoaded, sessions]);
-
- // Auto mark-as-read whenever the viewer is on a session with unread.
- const currentHasUnread =
- sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
- useEffect(() => {
- if (!activeSessionId || !currentHasUnread) return;
- markRead.mutate(activeSessionId);
- // eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
- }, [activeSessionId, currentHasUnread]);
-
- const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
-
- const handleSend = useCallback(
- async (content: string) => {
- if (!activeAgent) {
- apiLogger.warn("sendChatMessage skipped: no active agent");
- return;
- }
- const focusOn = useChatStore.getState().focusMode;
- const finalContent = focusOn && anchorCandidate
- ? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
- : content;
-
- let sessionId = activeSessionId;
- const isNewSession = !sessionId;
-
- apiLogger.info("sendChatMessage.start", {
- sessionId,
- isNewSession,
- agentId: activeAgent.id,
- contentLength: finalContent.length,
- });
-
- if (!sessionId) {
- const session = await createSession.mutateAsync({
- agent_id: activeAgent.id,
- title: finalContent.slice(0, 50),
- });
- sessionId = session.id;
- setActiveSession(sessionId);
- }
-
- const optimistic: ChatMessage = {
- id: `optimistic-${Date.now()}`,
- chat_session_id: sessionId,
- role: "user",
- content: finalContent,
- task_id: null,
- created_at: new Date().toISOString(),
- };
- qc.setQueryData
(
- chatKeys.messages(sessionId),
- (old) => (old ? [...old, optimistic] : [optimistic]),
- );
-
- const result = await api.sendChatMessage(sessionId, finalContent);
- qc.setQueryData(chatKeys.pendingTask(sessionId), {
- task_id: result.task_id,
- status: "queued",
- });
- qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
- },
- [activeSessionId, activeAgent, anchorCandidate, createSession, setActiveSession, qc],
- );
-
- const handleStop = useCallback(async () => {
- if (!pendingTaskId) return;
- try {
- await api.cancelTaskById(pendingTaskId);
- } catch (err) {
- apiLogger.warn("cancelTask.error", { taskId: pendingTaskId, err });
- }
- if (activeSessionId) {
- qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
- qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
- }
- }, [pendingTaskId, activeSessionId, qc]);
-
- const handleSelectAgent = useCallback(
- (agent: Agent) => {
- if (activeAgent && agent.id === activeAgent.id) return;
- uiLogger.info("selectAgent", { from: selectedAgentId, to: agent.id });
- setSelectedAgentId(agent.id);
- setActiveSession(null);
- },
- [activeAgent, selectedAgentId, setSelectedAgentId, setActiveSession],
- );
-
- const handleNewChat = useCallback(() => {
- setActiveSession(null);
- }, [setActiveSession]);
-
- const handleSelectSession = useCallback(
- (session: ChatSession) => {
- if (activeAgent && session.agent_id !== activeAgent.id) {
- setSelectedAgentId(session.agent_id);
- }
- setActiveSession(session.id);
- },
- [activeAgent, setSelectedAgentId, setActiveSession],
- );
-
- const hasMessages = messages.length > 0 || !!pendingTaskId;
- const activeTitle = currentSession?.title?.trim() || "New chat";
-
- return (
-
-
- {activeTitle}
-
-
-
-
- }
- >
-
-
- New chat
-
-
-
-
- {/* Body — centered max-width column */}
-
- {showSkeleton ? (
-
-
-
- ) : hasMessages ? (
-
-
-
- ) : (
-
- )}
-
-
-
}
- leftAdornment={
-
- }
- rightAdornment={
}
- />
-
-
-
- );
-}
-
-/**
- * Popover-based history list. Per product direction, session history lives
- * inside the Chat tab — not in the global sidebar — so that Multica doesn't
- * read as "just another chat app." The trigger is a History icon in the
- * page header.
- */
-function HistoryPopover({
- sessions,
- agents,
- activeSessionId,
- onSelectSession,
-}: {
- sessions: ChatSession[];
- agents: Agent[];
- activeSessionId: string | null;
- onSelectSession: (session: ChatSession) => void;
-}) {
- const [open, setOpen] = React.useState(false);
- const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
-
- return (
-
-
-
- }
- />
- }
- >
-
-
- History
-
-
-
- History
-
-
- {sessions.length === 0 ? (
-
- No previous chats
-
- ) : (
- sessions.map((session) => {
- const isCurrent = session.id === activeSessionId;
- const agent = agentById.get(session.agent_id) ?? null;
- return (
-
{
- onSelectSession(session);
- setOpen(false);
- }}
- className={cn(
- "flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/60",
- isCurrent && "bg-accent/40",
- )}
- >
-
-
-
- {session.title?.trim() || "New chat"}
-
-
- {agent?.name ?? "Unknown agent"}
-
-
- {session.has_unread && (
-
- )}
- {isCurrent && (
-
- )}
-
- );
- })
- )}
-
-
-
- );
-}
-
-function AgentDropdown({
- agents,
- activeAgent,
- userId,
- onSelect,
-}: {
- agents: Agent[];
- activeAgent: Agent | null;
- userId: string | undefined;
- onSelect: (agent: Agent) => void;
-}) {
- const { mine, others } = useMemo(() => {
- const mine: Agent[] = [];
- const others: Agent[] = [];
- for (const a of agents) {
- if (a.owner_id === userId) mine.push(a);
- else others.push(a);
- }
- return { mine, others };
- }, [agents, userId]);
-
- if (!activeAgent) {
- return No agents ;
- }
-
- return (
-
-
-
- {activeAgent.name}
-
-
-
- {mine.length > 0 && (
-
- My agents
- {mine.map((agent) => (
-
- ))}
-
- )}
- {mine.length > 0 && others.length > 0 && }
- {others.length > 0 && (
-
- Others
- {others.map((agent) => (
-
- ))}
-
- )}
-
-
- );
-}
-
-function AgentMenuItem({
- agent,
- isCurrent,
- onSelect,
-}: {
- agent: Agent;
- isCurrent: boolean;
- onSelect: (agent: Agent) => void;
-}) {
- return (
- onSelect(agent)}
- className="flex min-w-0 items-center gap-2"
- >
-
- {agent.name}
- {isCurrent && }
-
- );
-}
-
-function AgentAvatarSmall({ agent }: { agent: Agent | null }) {
- return (
-
- {agent?.avatar_url && }
-
-
-
-
- );
-}
-
-const STARTER_PROMPTS: { icon: string; text: string }[] = [
- { icon: "📋", text: "List my open tasks by priority" },
- { icon: "📝", text: "Summarize what I did today" },
- { icon: "💡", text: "Plan what to work on next" },
-];
-
-function EmptyState({
- agentName,
- onPickPrompt,
-}: {
- agentName?: string;
- onPickPrompt: (text: string) => void;
-}) {
- return (
-
-
-
- {agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
-
-
How can I help?
-
-
- {STARTER_PROMPTS.map((prompt) => (
- onPickPrompt(prompt.text)}
- className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
- >
- {prompt.icon}
- {prompt.text}
-
- ))}
-
-
- );
-}
diff --git a/packages/views/chat/components/chat-resize-handles.tsx b/packages/views/chat/components/chat-resize-handles.tsx
new file mode 100644
index 0000000000..df67b60c9f
--- /dev/null
+++ b/packages/views/chat/components/chat-resize-handles.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import React from "react";
+
+type DragDir = "left" | "top" | "corner";
+
+interface ChatResizeHandlesProps {
+ onDragStart: (e: React.PointerEvent, dir: DragDir) => void;
+}
+
+export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) {
+ return (
+ <>
+ {/* Left edge — expands width when dragged left */}
+ onDragStart(e, "left")}
+ className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize"
+ />
+ {/* Top edge — expands height when dragged up */}
+
onDragStart(e, "top")}
+ className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
+ />
+ {/* Top-left corner — expands both width and height */}
+
onDragStart(e, "corner")}
+ className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
+ />
+ >
+ );
+}
diff --git a/packages/views/chat/components/chat-session-history.tsx b/packages/views/chat/components/chat-session-history.tsx
new file mode 100644
index 0000000000..fe731785fc
--- /dev/null
+++ b/packages/views/chat/components/chat-session-history.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { ArrowLeft, MessageSquare, Bot } from "lucide-react";
+import { cn } from "@multica/ui/lib/utils";
+import { Button } from "@multica/ui/components/ui/button";
+import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
+import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
+import { useWorkspaceId } from "@multica/core/hooks";
+import { agentListOptions } from "@multica/core/workspace/queries";
+import { allChatSessionsOptions } from "@multica/core/chat/queries";
+import { useChatStore } from "@multica/core/chat";
+import { createLogger } from "@multica/core/logger";
+import type { ChatSession, Agent } from "@multica/core/types";
+
+const logger = createLogger("chat.ui");
+
+export function ChatSessionHistory() {
+ const wsId = useWorkspaceId();
+ const setShowHistory = useChatStore((s) => s.setShowHistory);
+ const setActiveSession = useChatStore((s) => s.setActiveSession);
+ const activeSessionId = useChatStore((s) => s.activeSessionId);
+
+ const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
+ const { data: agents = [] } = useQuery(agentListOptions(wsId));
+
+ const agentMap = new Map(agents.map((a) => [a.id, a]));
+
+ const handleSelectSession = (session: ChatSession) => {
+ logger.info("selectSession", {
+ from: activeSessionId,
+ to: session.id,
+ agentId: session.agent_id,
+ status: session.status,
+ });
+ // Changing activeSessionId flips the query keys for messages +
+ // pending-task; no manual clear needed.
+ setActiveSession(session.id);
+ setShowHistory(false);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ setShowHistory(false)}
+ />
+ }
+ >
+
+
+ Back
+
+
Chat History
+
+
+ {/* Session list */}
+
+ {sessions.length === 0 ? (
+
+
+ No chat sessions yet
+
+ ) : (
+
+ {sessions.map((session) => (
+ handleSelectSession(session)}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
+
+function SessionItem({
+ session,
+ agent,
+ isActive,
+ onSelect,
+}: {
+ session: ChatSession;
+ agent: Agent | null;
+ isActive: boolean;
+ onSelect: () => void;
+}) {
+ const timeAgo = formatTimeAgo(session.updated_at);
+
+ return (
+
+
+ {agent?.avatar_url && }
+
+
+
+
+
+
+
+ {session.title || "Untitled"}
+
+
+
+ {agent && (
+
+ {agent.name}
+
+ )}
+ {timeAgo}
+
+
+
+ );
+}
+
+function formatTimeAgo(dateStr: string): string {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return "just now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+}
diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx
new file mode 100644
index 0000000000..81ba095a09
--- /dev/null
+++ b/packages/views/chat/components/chat-window.tsx
@@ -0,0 +1,648 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
+import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
+import { Button } from "@multica/ui/components/ui/button";
+import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@multica/ui/components/ui/dropdown-menu";
+import { useWorkspaceId } from "@multica/core/hooks";
+import { useAuthStore } from "@multica/core/auth";
+import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
+import { canAssignAgent } from "@multica/views/issues/components";
+import { api } from "@multica/core/api";
+import {
+ chatSessionsOptions,
+ allChatSessionsOptions,
+ chatMessagesOptions,
+ pendingChatTaskOptions,
+ chatKeys,
+} from "@multica/core/chat/queries";
+import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
+import { useChatStore } from "@multica/core/chat";
+import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
+import { ChatInput } from "./chat-input";
+import {
+ ContextAnchorButton,
+ ContextAnchorCard,
+ buildAnchorMarkdown,
+ useRouteAnchorCandidate,
+} from "./context-anchor";
+import { ChatResizeHandles } from "./chat-resize-handles";
+import { useChatResize } from "./use-chat-resize";
+import { createLogger } from "@multica/core/logger";
+import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
+
+const uiLogger = createLogger("chat.ui");
+const apiLogger = createLogger("chat.api");
+
+export function ChatWindow() {
+ const wsId = useWorkspaceId();
+ const isOpen = useChatStore((s) => s.isOpen);
+ const activeSessionId = useChatStore((s) => s.activeSessionId);
+ const selectedAgentId = useChatStore((s) => s.selectedAgentId);
+ const setOpen = useChatStore((s) => s.setOpen);
+ const setActiveSession = useChatStore((s) => s.setActiveSession);
+ const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
+ const user = useAuthStore((s) => s.user);
+ const { data: agents = [] } = useQuery(agentListOptions(wsId));
+ const { data: members = [] } = useQuery(memberListOptions(wsId));
+ const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
+ const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
+ const { data: rawMessages, isLoading: messagesLoading } = useQuery(
+ chatMessagesOptions(activeSessionId ?? ""),
+ );
+ // When no active session, always show empty — don't use stale cache
+ const messages = activeSessionId ? rawMessages ?? [] : [];
+ // Skeleton only shows for an un-cached session fetch. Cached switches
+ // return data synchronously — no flash. `enabled: false` (new chat)
+ // keeps isLoading false so the starter prompts aren't hidden.
+ const showSkeleton = !!activeSessionId && messagesLoading;
+
+ // Server-authoritative pending task. Survives refresh / reopen / session
+ // switch because it's keyed on sessionId in the Query cache; WS events
+ // (chat:message / chat:done / task:*) keep it invalidated in real time.
+ //
+ // This is the SOLE source for pendingTaskId — no mirror in the store.
+ const { data: pendingTask } = useQuery(
+ pendingChatTaskOptions(activeSessionId ?? ""),
+ );
+ const pendingTaskId = pendingTask?.task_id ?? null;
+
+ // Check if current session is archived
+ const currentSession = activeSessionId
+ ? allSessions.find((s) => s.id === activeSessionId)
+ : null;
+ const isSessionArchived = currentSession?.status === "archived";
+
+ const qc = useQueryClient();
+ const createSession = useCreateChatSession();
+ const markRead = useMarkChatSessionRead();
+
+ const currentMember = members.find((m) => m.user_id === user?.id);
+ const memberRole = currentMember?.role;
+ const availableAgents = agents.filter(
+ (a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
+ );
+
+ // Resolve selected agent: stored preference → first available
+ const activeAgent =
+ availableAgents.find((a) => a.id === selectedAgentId) ??
+ availableAgents[0] ??
+ null;
+
+ // Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
+ // fires on layout mount (login / workspace switch / fresh page load).
+ useEffect(() => {
+ uiLogger.info("ChatWindow mount", {
+ isOpen,
+ activeSessionId,
+ pendingTaskId,
+ selectedAgentId,
+ wsId,
+ });
+ return () => {
+ uiLogger.info("ChatWindow unmount", {
+ activeSessionId,
+ pendingTaskId,
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
+ }, []);
+
+ // Auto-restore most recent active session from server (only once on mount)
+ const didRestoreRef = useRef(false);
+ useEffect(() => {
+ if (didRestoreRef.current) return;
+ didRestoreRef.current = true;
+ if (activeSessionId || sessions.length === 0) {
+ uiLogger.debug("restore session skipped", {
+ reason: activeSessionId ? "already has session" : "no sessions",
+ activeSessionId,
+ sessionCount: sessions.length,
+ });
+ return;
+ }
+ const latest = sessions.find((s) => s.status === "active");
+ if (latest) {
+ uiLogger.info("restore session on mount", { sessionId: latest.id });
+ setActiveSession(latest.id);
+ } else {
+ uiLogger.debug("restore session: no active session found");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
+ }, [sessions]);
+
+ // WS events are handled globally in useRealtimeSync — the query cache
+ // stays current even when this window is closed. See packages/core/realtime/.
+
+ // Auto mark-as-read whenever the user is looking at a session with unread
+ // state: window open + a session active + has_unread → PATCH.
+ // has_unread comes from the list query; WS handlers invalidate it on
+ // chat:done so a reply arriving while the user watches triggers this
+ // effect again and is instantly cleared.
+ const currentHasUnread =
+ sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
+ useEffect(() => {
+ if (!isOpen || !activeSessionId) return;
+ if (!currentHasUnread) return;
+ uiLogger.info("auto markRead", { sessionId: activeSessionId });
+ markRead.mutate(activeSessionId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
+ }, [isOpen, activeSessionId, currentHasUnread]);
+
+ // Focus-mode anchor: derived from route each render. Prepended to the
+ // outgoing message when focus is on; the anchor persists across sends
+ // (focus mode tracks the user's page, not a per-message attachment).
+ const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
+
+ const handleSend = useCallback(
+ async (content: string) => {
+ if (!activeAgent) {
+ apiLogger.warn("sendChatMessage skipped: no active agent");
+ return;
+ }
+
+ const focusOn = useChatStore.getState().focusMode;
+ const finalContent = focusOn && anchorCandidate
+ ? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
+ : content;
+
+ let sessionId = activeSessionId;
+ const isNewSession = !sessionId;
+
+ apiLogger.info("sendChatMessage.start", {
+ sessionId,
+ isNewSession,
+ agentId: activeAgent.id,
+ contentLength: finalContent.length,
+ hasAnchor: focusOn && !!anchorCandidate,
+ });
+
+ if (!sessionId) {
+ const session = await createSession.mutateAsync({
+ agent_id: activeAgent.id,
+ title: finalContent.slice(0, 50),
+ });
+ sessionId = session.id;
+ setActiveSession(sessionId);
+ }
+
+ // Optimistic: show user message immediately.
+ const optimistic: ChatMessage = {
+ id: `optimistic-${Date.now()}`,
+ chat_session_id: sessionId,
+ role: "user",
+ content: finalContent,
+ task_id: null,
+ created_at: new Date().toISOString(),
+ };
+ qc.setQueryData
(
+ chatKeys.messages(sessionId),
+ (old) => (old ? [...old, optimistic] : [optimistic]),
+ );
+ apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
+
+ const result = await api.sendChatMessage(sessionId, finalContent);
+ apiLogger.info("sendChatMessage.success", {
+ sessionId,
+ messageId: result.message_id,
+ taskId: result.task_id,
+ });
+ // Seed pending-task optimistically so the spinner shows instantly —
+ // the WS chat:message handler will invalidate + refetch to confirm.
+ qc.setQueryData(chatKeys.pendingTask(sessionId), {
+ task_id: result.task_id,
+ status: "queued",
+ });
+ qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
+ },
+ [
+ activeSessionId,
+ activeAgent,
+ anchorCandidate,
+ createSession,
+ setActiveSession,
+ qc,
+ ],
+ );
+
+ const handleStop = useCallback(async () => {
+ if (!pendingTaskId) {
+ apiLogger.debug("cancelTask skipped: no pending task");
+ return;
+ }
+ apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
+ try {
+ await api.cancelTaskById(pendingTaskId);
+ apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
+ } catch (err) {
+ // Task may already be completed
+ apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
+ }
+ if (activeSessionId) {
+ // Clear pending immediately; WS task:cancelled will confirm.
+ qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
+ qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
+ }
+ }, [pendingTaskId, activeSessionId, qc]);
+
+ const handleSelectAgent = useCallback(
+ (agent: Agent) => {
+ // No-op when clicking the already-active agent — don't clobber the
+ // current session just because the user closed the menu this way.
+ // Compare against activeAgent (what the UI shows), not selectedAgentId
+ // (which may be null / point to an archived agent on first load).
+ if (activeAgent && agent.id === activeAgent.id) return;
+ uiLogger.info("selectAgent", {
+ from: selectedAgentId,
+ to: agent.id,
+ previousSessionId: activeSessionId,
+ });
+ setSelectedAgentId(agent.id);
+ // Reset session when switching agent
+ setActiveSession(null);
+ },
+ [activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
+ );
+
+ const handleNewChat = useCallback(() => {
+ uiLogger.info("newChat", {
+ previousSessionId: activeSessionId,
+ previousPendingTask: pendingTaskId,
+ });
+ setActiveSession(null);
+ }, [activeSessionId, pendingTaskId, setActiveSession]);
+
+ const handleSelectSession = useCallback(
+ (session: ChatSession) => {
+ // Sessions are bound 1:1 to an agent — picking a session from a
+ // different agent implicitly switches the agent too.
+ if (activeAgent && session.agent_id !== activeAgent.id) {
+ uiLogger.info("selectSession (cross-agent)", {
+ from: activeAgent.id,
+ toAgent: session.agent_id,
+ toSession: session.id,
+ });
+ setSelectedAgentId(session.agent_id);
+ }
+ setActiveSession(session.id);
+ },
+ [activeAgent, setSelectedAgentId, setActiveSession],
+ );
+
+ const handleMinimize = useCallback(() => {
+ uiLogger.info("minimize (close)", {
+ activeSessionId,
+ pendingTaskId,
+ });
+ setOpen(false);
+ }, [activeSessionId, pendingTaskId, setOpen]);
+
+ const windowRef = useRef(null);
+ const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
+
+ // Show the list (vs empty state) as soon as there's anything to display —
+ // a real message, or a pending task whose timeline will stream in.
+ const hasMessages = messages.length > 0 || !!pendingTaskId;
+
+ const isVisible = isOpen && boundsReady;
+
+ const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
+ const containerStyle: React.CSSProperties = {
+ width: `${renderWidth}px`,
+ height: `${renderHeight}px`,
+ opacity: isVisible ? 1 : 0,
+ transform: isVisible ? "scale(1)" : "scale(0.95)",
+ transformOrigin: "bottom right",
+ pointerEvents: isOpen ? "auto" : "none",
+ transition: isDragging
+ ? "none"
+ : "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
+ };
+
+ return (
+
+
+ {/* Header — ⊕ new + session dropdown | window tools */}
+
+
+
+
+ }
+ >
+
+
+ New chat
+
+
+
+
+
+
+ }
+ >
+ {isAtMax ? : }
+
+
+ {isAtMax ? "Restore" : "Expand"}
+
+
+
+
+ }
+ >
+
+
+ Minimize
+
+
+
+
+ {/* Messages / skeleton / empty state */}
+ {showSkeleton ? (
+
+ ) : hasMessages ? (
+
+ ) : (
+
handleSend(text)}
+ />
+ )}
+
+ {/* Input — disabled for archived sessions */}
+ }
+ leftAdornment={
+
+ }
+ rightAdornment={ }
+ />
+
+ );
+}
+
+/**
+ * Agent dropdown: avatar trigger, lists all available agents. Selecting a
+ * different agent = switch agent + start a fresh chat (session=null).
+ * The current agent is marked with a check and not clickable.
+ */
+function AgentDropdown({
+ agents,
+ activeAgent,
+ userId,
+ onSelect,
+}: {
+ agents: Agent[];
+ activeAgent: Agent | null;
+ userId: string | undefined;
+ onSelect: (agent: Agent) => void;
+}) {
+ // Split into the user's own agents and everyone else so the menu groups
+ // them — matches the old AgentSelector layout.
+ const { mine, others } = useMemo(() => {
+ const mine: Agent[] = [];
+ const others: Agent[] = [];
+ for (const a of agents) {
+ if (a.owner_id === userId) mine.push(a);
+ else others.push(a);
+ }
+ return { mine, others };
+ }, [agents, userId]);
+
+ if (!activeAgent) {
+ return No agents ;
+ }
+
+ return (
+
+
+
+ {activeAgent.name}
+
+
+
+ {mine.length > 0 && (
+
+ My agents
+ {mine.map((agent) => (
+
+ ))}
+
+ )}
+ {mine.length > 0 && others.length > 0 && }
+ {others.length > 0 && (
+
+ Others
+ {others.map((agent) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function AgentMenuItem({
+ agent,
+ isCurrent,
+ onSelect,
+}: {
+ agent: Agent;
+ isCurrent: boolean;
+ onSelect: (agent: Agent) => void;
+}) {
+ return (
+ onSelect(agent)}
+ className="flex min-w-0 items-center gap-2"
+ >
+
+ {agent.name}
+ {isCurrent && }
+
+ );
+}
+
+/**
+ * Session dropdown: lists ALL sessions across agents. Each row carries the
+ * owning agent's avatar so the user can tell them apart. Selecting a
+ * session from a different agent implicitly switches the agent too
+ * (sessions are bound 1:1 to an agent). "New chat" lives in the header's
+ * ⊕ button, not inside this dropdown.
+ */
+function SessionDropdown({
+ sessions,
+ agents,
+ activeSessionId,
+ onSelectSession,
+}: {
+ sessions: ChatSession[];
+ agents: Agent[];
+ activeSessionId: string | null;
+ onSelectSession: (session: ChatSession) => void;
+}) {
+ const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
+ const activeSession = sessions.find((s) => s.id === activeSessionId);
+ const title = activeSession?.title?.trim() || "New chat";
+ const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
+
+ return (
+
+
+ {triggerAgent && }
+ {title}
+
+
+
+ {sessions.length === 0 ? (
+
+ No previous chats
+
+ ) : (
+ sessions.map((session) => {
+ const isCurrent = session.id === activeSessionId;
+ const agent = agentById.get(session.agent_id) ?? null;
+ return (
+ onSelectSession(session)}
+ className="flex min-w-0 items-center gap-2"
+ >
+ {agent ? (
+
+ ) : (
+
+ )}
+
+ {session.title?.trim() || "New chat"}
+
+ {session.has_unread && (
+
+ )}
+ {isCurrent && }
+
+ );
+ })
+ )}
+
+
+ );
+}
+
+function AgentAvatarSmall({ agent }: { agent: Agent }) {
+ return (
+
+ {agent.avatar_url && }
+
+
+
+
+ );
+}
+
+/**
+ * Three starter prompts shown on the empty state. Tapping one sends it
+ * immediately — ChatGPT-style — because the point is showing users what
+ * this chat is for: operating on the workspace, not open-ended Q&A.
+ */
+const STARTER_PROMPTS: { icon: string; text: string }[] = [
+ { icon: "📋", text: "List my open tasks by priority" },
+ { icon: "📝", text: "Summarize what I did today" },
+ { icon: "💡", text: "Plan what to work on next" },
+];
+
+function EmptyState({
+ agentName,
+ onPickPrompt,
+}: {
+ agentName?: string;
+ onPickPrompt: (text: string) => void;
+}) {
+ return (
+
+
+
+ {agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
+
+
Try asking
+
+
+ {STARTER_PROMPTS.map((prompt) => (
+ onPickPrompt(prompt.text)}
+ className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
+ >
+ {prompt.icon}
+ {prompt.text}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/views/chat/components/context-anchor.tsx b/packages/views/chat/components/context-anchor.tsx
index 23dc2974f1..27c4f39c93 100644
--- a/packages/views/chat/components/context-anchor.tsx
+++ b/packages/views/chat/components/context-anchor.tsx
@@ -1,6 +1,5 @@
"use client";
-import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Focus } from "lucide-react";
import type { ContextAnchor } from "@multica/core/chat";
@@ -34,42 +33,11 @@ export function buildAnchorMarkdown(anchor: ContextAnchor): string {
return `Context: Project "${anchor.label}"`;
}
-/**
- * Returns true when the given pathname can resolve to an anchor candidate
- * (issue detail, project detail, or inbox). Used by both the resolver and
- * the tracker so they agree on which routes are anchor-eligible.
- */
-function isAnchorEligiblePath(pathname: string): boolean {
- if (/^\/[^/]+\/issues\/[^/]+$/.test(pathname)) return true;
- if (/^\/[^/]+\/projects\/[^/]+$/.test(pathname)) return true;
- if (/^\/[^/]+\/inbox$/.test(pathname)) return true;
- return false;
-}
-
-/**
- * Runs an effect that remembers the last anchor-eligible location the user
- * visited. Mount this in a component that's present on every page (the app
- * sidebar) so the chat page — which is its own route and therefore has no
- * anchor of its own — can still know what the user was just looking at.
- */
-export function useAnchorTracker(): void {
- const { pathname, searchParams } = useNavigation();
- const setLastAnchorLocation = useChatStore((s) => s.setLastAnchorLocation);
- useEffect(() => {
- if (!isAnchorEligiblePath(pathname)) return;
- setLastAnchorLocation({ pathname, search: searchParams.toString() });
- }, [pathname, searchParams, setLastAnchorLocation]);
-}
-
/**
* Resolve the current page into an anchorable candidate, or null if the user
* is somewhere without a natural focus object. Subscribes via react-query so
* the result updates the instant the relevant cache fills.
*
- * When the user is on the Chat route (no intrinsic anchor), falls back to
- * the last anchor-eligible location remembered by `useAnchorTracker`, so
- * "open Chat from an issue → focus mode still attaches that issue" works.
- *
* `wsId` is passed in (per CLAUDE.md convention) so this hook works outside
* a WorkspaceIdProvider if ever reused elsewhere.
*/
@@ -78,20 +46,10 @@ export function useRouteAnchorCandidate(wsId: string): {
isResolving: boolean;
} {
const { pathname, searchParams } = useNavigation();
- const lastAnchorLocation = useChatStore((s) => s.lastAnchorLocation);
- // On the Chat route there's no intrinsic anchor; substitute the last
- // anchor-eligible location the user visited. Anywhere else, use the
- // live route directly.
- const useFallback = !isAnchorEligiblePath(pathname) && !!lastAnchorLocation;
- const effectivePath = useFallback ? lastAnchorLocation!.pathname : pathname;
- const effectiveSearch = useFallback
- ? new URLSearchParams(lastAnchorLocation!.search)
- : searchParams;
-
- const issueMatch = effectivePath.match(/^\/[^/]+\/issues\/([^/]+)$/);
- const projectMatch = effectivePath.match(/^\/[^/]+\/projects\/([^/]+)$/);
- const isInbox = /^\/[^/]+\/inbox$/.test(effectivePath);
+ const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/);
+ const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
+ const isInbox = /^\/[^/]+\/inbox$/.test(pathname);
const routeIssueId = issueMatch ? decodeURIComponent(issueMatch[1]!) : null;
const routeProjectId = projectMatch
@@ -103,7 +61,7 @@ export function useRouteAnchorCandidate(wsId: string): {
...inboxListOptions(wsId),
enabled: isInbox,
});
- const inboxKey = isInbox ? effectiveSearch.get("issue") : null;
+ const inboxKey = isInbox ? searchParams.get("issue") : null;
const inboxSelectedIssueId =
isInbox && inboxKey
? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ??
diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts
new file mode 100644
index 0000000000..01a53ddeb7
--- /dev/null
+++ b/packages/views/chat/components/use-chat-resize.ts
@@ -0,0 +1,140 @@
+"use client";
+
+import React, { useRef, useCallback, useState, useEffect } from "react";
+import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
+
+type DragDir = "left" | "top" | "corner";
+
+const MAX_RATIO = 0.9;
+const FALLBACK_MAX_W = 800;
+const FALLBACK_MAX_H = 700;
+
+function clamp(v: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, v));
+}
+
+export function useChatResize(
+ windowRef: React.RefObject,
+) {
+ const chatWidth = useChatStore((s) => s.chatWidth);
+ const chatHeight = useChatStore((s) => s.chatHeight);
+ const isExpanded = useChatStore((s) => s.isExpanded);
+ const setChatSize = useChatStore((s) => s.setChatSize);
+ const setExpanded = useChatStore((s) => s.setExpanded);
+
+ // ── Container bounds via ResizeObserver ────────────────────────────────
+ const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
+ const [boundsReady, setBoundsReady] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [, setRevision] = useState(0);
+
+ useEffect(() => {
+ const el = windowRef.current;
+ const parent = el?.parentElement;
+ if (!parent) return;
+
+ const update = () => {
+ const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
+ const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
+ setBoundsReady(true); // idempotent once true
+ // Only trigger a re-render if the bounds actually changed. Without this
+ // guard, any spurious ResizeObserver notification (including sub-pixel
+ // layout jitter during mount) schedules a setState that feeds back into
+ // the observer, producing "Maximum update depth exceeded".
+ const prev = boundsRef.current;
+ if (prev.maxW === maxW && prev.maxH === maxH) return;
+ boundsRef.current = { maxW, maxH };
+ setRevision((r) => r + 1);
+ };
+
+ // Measure immediately (parent is already in DOM at this point)
+ update();
+
+ const ro = new ResizeObserver(update);
+ ro.observe(parent);
+ return () => ro.disconnect();
+ }, [windowRef]);
+
+ // ── Derive rendered size ──────────────────────────────────────────────
+ const { maxW, maxH } = boundsRef.current;
+
+ const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
+ const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
+
+ // ── Expand / Restore ──────────────────────────────────────────────────
+ const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
+
+ const toggleExpand = useCallback(() => {
+ if (isExpanded || isAtMax) {
+ setChatSize(CHAT_MIN_W, CHAT_MIN_H);
+ } else {
+ setExpanded(true);
+ }
+ }, [isExpanded, isAtMax, setChatSize, setExpanded]);
+
+ // ── Drag ──────────────────────────────────────────────────────────────
+ const dragRef = useRef<{
+ startX: number;
+ startY: number;
+ startW: number;
+ startH: number;
+ dir: DragDir;
+ } | null>(null);
+
+ const startDrag = useCallback(
+ (e: React.PointerEvent, dir: DragDir) => {
+ e.preventDefault();
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+
+ dragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ startW: renderWidth,
+ startH: renderHeight,
+ dir,
+ };
+ setIsDragging(true);
+
+ const onPointerMove = (ev: PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+
+ const { maxW: mw, maxH: mh } = boundsRef.current;
+
+ const rawW =
+ dir === "left" || dir === "corner"
+ ? d.startW - (ev.clientX - d.startX)
+ : d.startW;
+ const rawH =
+ dir === "top" || dir === "corner"
+ ? d.startH - (ev.clientY - d.startY)
+ : d.startH;
+
+ setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
+ };
+
+ const onPointerUp = () => {
+ dragRef.current = null;
+ setIsDragging(false);
+ document.removeEventListener("pointermove", onPointerMove);
+ document.removeEventListener("pointerup", onPointerUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+
+ const cursorMap: Record = {
+ left: "col-resize",
+ top: "row-resize",
+ corner: "nw-resize",
+ };
+ document.body.style.cursor = cursorMap[dir];
+ document.body.style.userSelect = "none";
+ },
+ [renderWidth, renderHeight, setChatSize],
+ );
+
+ return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
+}
diff --git a/packages/views/chat/index.ts b/packages/views/chat/index.ts
index c898be125c..04295ff1f8 100644
--- a/packages/views/chat/index.ts
+++ b/packages/views/chat/index.ts
@@ -1 +1,2 @@
-export { ChatPage } from "./components/chat-page";
+export { ChatFab } from "./components/chat-fab";
+export { ChatWindow } from "./components/chat-window";
diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx
index ef60322fe0..f505065f28 100644
--- a/packages/views/editor/extensions/mention-suggestion.test.tsx
+++ b/packages/views/editor/extensions/mention-suggestion.test.tsx
@@ -1,3 +1,5 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { createRef } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { issueKeys, PAGINATED_STATUSES } from "@multica/core/issues/queries";
@@ -19,7 +21,12 @@ vi.mock("@multica/core/api", () => ({
},
}));
-import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
+import {
+ createMentionSuggestion,
+ MentionList,
+ type MentionListRef,
+ type MentionItem,
+} from "./mention-suggestion";
function fakeQc(data: {
members?: Array<{ user_id: string; name: string }>;
@@ -66,34 +73,50 @@ describe("createMentionSuggestion", () => {
expect(items.some((i) => i.type === "agent" && i.label === "Aegis")).toBe(true);
});
- it("calls searchIssues with include_closed=true so done issues are findable", async () => {
- const qc = fakeQc({});
- searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
+ it("loads server issue matches into the popup when the list cache misses", async () => {
+ searchIssuesMock.mockResolvedValue({
+ issues: [
+ {
+ id: "i-1007",
+ identifier: "MUL-1007",
+ title: "多 Agent 协作探索",
+ status: "done",
+ },
+ ],
+ total: 1,
+ });
- const config = createMentionSuggestion(qc);
- config.items!({ query: "bug-xyz", editor: {} as never });
+ render( );
- // Wait past the 150ms debounce.
- await new Promise((r) => setTimeout(r, 200));
+ expect(screen.getByText("Searching...")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText("MUL-1007")).toBeInTheDocument();
+ });
+ expect(screen.getByText("多 Agent 协作探索")).toBeInTheDocument();
expect(searchIssuesMock).toHaveBeenCalledWith(
- expect.objectContaining({ q: "bug-xyz", include_closed: true }),
+ expect.objectContaining({
+ q: "协作",
+ limit: 20,
+ include_closed: true,
+ }),
);
});
- it("does not call searchIssues for an empty query", async () => {
- const qc = fakeQc({});
- searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
+ it("does not call searchIssues for an empty query", () => {
+ render( );
- const config = createMentionSuggestion(qc);
- config.items!({ query: "", editor: {} as never });
+ expect(searchIssuesMock).not.toHaveBeenCalled();
+ });
- await new Promise((r) => setTimeout(r, 200));
- // No call with an empty q (other tests' fire-and-forget closures may leak,
- // so assert on the *content* of any call rather than absence).
- for (const call of searchIssuesMock.mock.calls) {
- expect(call[0].q).not.toBe("");
- }
+ it("captures Enter while the popup has no selectable items", () => {
+ const ref = createRef();
+
+ render( );
+
+ expect(
+ ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", { key: "Enter" }) }),
+ ).toBe(true);
});
it("includes cached issues in the synchronous response", () => {
diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx
index 02f86ff69f..ca4140f37f 100644
--- a/packages/views/editor/extensions/mention-suggestion.tsx
+++ b/packages/views/editor/extensions/mention-suggestion.tsx
@@ -5,6 +5,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
+ useMemo,
useRef,
useState,
} from "react";
@@ -15,7 +16,12 @@ import { getCurrentWsId } from "@multica/core/platform";
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
-import type { Issue, ListIssuesCache, MemberWithUser, Agent } from "@multica/core/types";
+import type {
+ Issue,
+ ListIssuesCache,
+ MemberWithUser,
+ Agent,
+} from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { StatusIcon } from "../../issues/components/status-icon";
import { Badge } from "@multica/ui/components/ui/badge";
@@ -38,6 +44,7 @@ export interface MentionItem {
interface MentionListProps {
items: MentionItem[];
+ query: string;
command: (item: MentionItem) => void;
}
@@ -76,14 +83,100 @@ function groupItems(items: MentionItem[]): MentionGroup[] {
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
-const MentionList = forwardRef(
- function MentionList({ items, command }, ref) {
+const MAX_ITEMS = 20;
+const SERVER_ISSUE_SEARCH_LIMIT = 20;
+const SERVER_SEARCH_DEBOUNCE_MS = 150;
+
+function mentionItemKey(item: MentionItem): string {
+ return `${item.type}:${item.id}`;
+}
+
+function mergeMentionItems(
+ syncItems: MentionItem[],
+ serverIssueItems: MentionItem[],
+): MentionItem[] {
+ const seen = new Set();
+ const merged: MentionItem[] = [];
+
+ for (const item of [...syncItems, ...serverIssueItems]) {
+ const key = mentionItemKey(item);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(item);
+ }
+
+ return merged;
+}
+
+export const MentionList = forwardRef(
+ function MentionList({ items, query, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
+ const [serverIssueItems, setServerIssueItems] = useState([]);
+ const [isSearchingIssues, setIsSearchingIssues] = useState(false);
+ const [searchedIssueQuery, setSearchedIssueQuery] = useState("");
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const normalizedQuery = query.trim();
+
+ useEffect(() => {
+ const q = normalizedQuery;
+ setServerIssueItems([]);
+
+ if (!q) {
+ setIsSearchingIssues(false);
+ setSearchedIssueQuery("");
+ return;
+ }
+
+ const wsId = getCurrentWsId();
+ if (!wsId) {
+ setIsSearchingIssues(false);
+ setSearchedIssueQuery(q);
+ return;
+ }
+
+ let cancelled = false;
+ const controller = new AbortController();
+ setIsSearchingIssues(true);
+
+ const timer = setTimeout(() => {
+ void (async () => {
+ try {
+ const res = await api.searchIssues({
+ q,
+ limit: SERVER_ISSUE_SEARCH_LIMIT,
+ include_closed: true,
+ signal: controller.signal,
+ });
+ if (!cancelled && !controller.signal.aborted) {
+ setServerIssueItems(res.issues.map(issueToMention));
+ }
+ } catch {
+ // Aborted or network error: keep the synchronous cache results.
+ } finally {
+ if (!cancelled && !controller.signal.aborted) {
+ setSearchedIssueQuery(q);
+ setIsSearchingIssues(false);
+ }
+ }
+ })();
+ }, SERVER_SEARCH_DEBOUNCE_MS);
+
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [normalizedQuery]);
+
+ const displayItems = useMemo(() => {
+ const currentServerIssueItems =
+ searchedIssueQuery === normalizedQuery ? serverIssueItems : [];
+ return mergeMentionItems(items, currentServerIssueItems).slice(0, MAX_ITEMS);
+ }, [items, normalizedQuery, searchedIssueQuery, serverIssueItems]);
useEffect(() => {
setSelectedIndex(0);
- }, [items]);
+ }, [displayItems]);
useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
@@ -91,23 +184,28 @@ const MentionList = forwardRef(
const selectItem = useCallback(
(index: number) => {
- const item = items[index];
+ const item = displayItems[index];
if (item) command(item);
},
- [items, command],
+ [displayItems, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
- setSelectedIndex((i) => (i + items.length - 1) % items.length);
+ if (displayItems.length === 0) return true;
+ setSelectedIndex(
+ (i) => (i + displayItems.length - 1) % displayItems.length,
+ );
return true;
}
if (event.key === "ArrowDown") {
- setSelectedIndex((i) => (i + 1) % items.length);
+ if (displayItems.length === 0) return true;
+ setSelectedIndex((i) => (i + 1) % displayItems.length);
return true;
}
if (event.key === "Enter") {
+ if (displayItems.length === 0) return true;
selectItem(selectedIndex);
return true;
}
@@ -115,15 +213,19 @@ const MentionList = forwardRef(
},
}));
- if (items.length === 0) {
+ if (displayItems.length === 0) {
+ const isWaitingForServer =
+ normalizedQuery !== "" &&
+ (isSearchingIssues || searchedIssueQuery !== normalizedQuery);
+
return (
- No results
+ {isWaitingForServer ? "Searching..." : "No results"}
);
}
- const groups = groupItems(items);
+ const groups = groupItems(displayItems);
// Build a flat index mapping: globalIndex → item
let globalIndex = 0;
@@ -232,18 +334,13 @@ function issueToMention(i: Pick
};
}
-const MAX_ITEMS = 15;
-
export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions,
"editor"
> {
- // Per-editor state lives in this closure so multiple ContentEditor instances
- // (e.g. comment input + reply box) don't abort each other's searches.
+ // Renderer/popup instances live in this closure so each ContentEditor owns
+ // its own TipTap suggestion popup lifecycle.
let renderer: ReactRenderer | null = null;
- let activeCommand: ((item: MentionItem) => void) | null = null;
- let searchSeq = 0;
- let searchAbort: AbortController | null = null;
let popup: HTMLDivElement | null = null;
function buildSyncItems(query: string): MentionItem[] {
@@ -277,8 +374,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
- // Cached issues give an instant first paint; the server search below
- // adds done/cancelled and any other matches not in the local cache.
+ // Cached issues give an instant first paint; MentionList adds server
+ // matches for done/cancelled and any other issues not in this cache.
const issueItems: MentionItem[] = cachedIssues
.filter(
(i) =>
@@ -290,68 +387,23 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
return [...allItem, ...memberItems, ...agentItems, ...issueItems];
}
- function startServerIssueSearch(query: string, syncItems: MentionItem[]) {
- // Supersede any in-flight search; the next-arrived response wins.
- if (searchAbort) searchAbort.abort();
- const mySeq = ++searchSeq;
- const wsId = getCurrentWsId();
- if (!wsId) return;
-
- void (async () => {
- // Debounce: skip the fetch if a newer keystroke arrives within 150ms.
- await new Promise((r) => setTimeout(r, 150));
- if (mySeq !== searchSeq) return;
-
- const controller = new AbortController();
- searchAbort = controller;
- try {
- const res = await api.searchIssues({
- q: query,
- limit: 10,
- include_closed: true,
- signal: controller.signal,
- });
- if (mySeq !== searchSeq) return;
- if (!renderer || !activeCommand) return;
-
- const existingIssueIds = new Set(
- syncItems.filter((i) => i.type === "issue").map((i) => i.id),
- );
- const extraIssueItems = res.issues
- .map(issueToMention)
- .filter((i) => !existingIssueIds.has(i.id));
- if (extraIssueItems.length === 0) return;
-
- const merged = [...syncItems, ...extraIssueItems].slice(0, MAX_ITEMS);
- renderer.updateProps({ items: merged, command: activeCommand });
- } catch {
- // Aborted or network error: nothing to do — sync items remain.
- }
- })();
- }
-
return {
items: ({ query }) => {
const syncItems = buildSyncItems(query);
- // Empty query has no server search — cached issues are enough, and
- // we still bump the seq to cancel any pending fetch from a prior key.
- if (query === "") {
- if (searchAbort) searchAbort.abort();
- ++searchSeq;
- } else {
- startServerIssueSearch(query, syncItems);
- }
- return syncItems.slice(0, MAX_ITEMS);
+ return syncItems;
},
render: () => {
return {
onStart: (props: SuggestionProps) => {
renderer = new ReactRenderer(MentionList, {
- props: { items: props.items, command: props.command },
+ props: {
+ items: props.items,
+ query: props.query,
+ command: props.command,
+ },
editor: props.editor,
});
- activeCommand = props.command;
popup = document.createElement("div");
popup.style.position = "fixed";
@@ -365,9 +417,9 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
onUpdate: (props: SuggestionProps) => {
renderer?.updateProps({
items: props.items,
+ query: props.query,
command: props.command,
});
- activeCommand = props.command;
if (popup) updatePosition(popup, props.clientRect);
},
@@ -405,13 +457,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
function cleanup() {
renderer?.destroy();
renderer = null;
- activeCommand = null;
popup?.remove();
popup = null;
- // Cancel any in-flight server search; its result would target a
- // destroyed renderer.
- if (searchAbort) searchAbort.abort();
- ++searchSeq;
}
},
};
diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx
index 280a9aac12..523d4a81aa 100644
--- a/packages/views/editor/readonly-content.test.tsx
+++ b/packages/views/editor/readonly-content.test.tsx
@@ -55,3 +55,20 @@ describe("ReadonlyContent math rendering", () => {
expect(text).toContain("\\int_0^1 x^2 \\, dx");
});
});
+
+describe("ReadonlyContent line breaks", () => {
+ // Issue panel comments are the primary user-visible surface for agent
+ // output. CommonMark's default soft-break behavior collapses single
+ // newlines into spaces; agent text often relies on a single newline as a
+ // visible break. remark-breaks must remain wired into ReadonlyContent's
+ // remark plugin chain or comments lose their formatting again.
+ it("converts a single newline into a ", () => {
+ const { container } = render( );
+ expect(container.querySelector("br")).not.toBeNull();
+ });
+
+ it("renders a blank-line gap as separate paragraphs", () => {
+ const { container } = render( );
+ expect(container.querySelectorAll("p").length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx
index 6171f546d8..e1b9f6047a 100644
--- a/packages/views/editor/readonly-content.tsx
+++ b/packages/views/editor/readonly-content.tsx
@@ -22,6 +22,7 @@ import ReactMarkdown, {
type Components,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
+import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeRaw from "rehype-raw";
@@ -298,7 +299,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
return (
{
const input = "见 [link](https://example.com/x。)后文";
expect(preprocessLinks(input)).toBe(input);
});
+
+ it("does not linkify fuzzy domains inside existing markdown link labels", () => {
+ const input =
+ "数据来源:[NBA.com Schedule](https://www.nba.com/schedule)、[NBC Insider](https://www.nbc.com/nbc-insider/every-nba-playoff-game-this-week-on-nbc-peacock-april-25-28)";
+
+ expect(preprocessLinks(input)).toBe(input);
+ });
+
+ it("still linkifies fuzzy domains outside existing markdown links", () => {
+ const input = "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 NBA.com";
+
+ expect(preprocessLinks(input)).toBe(
+ "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 [NBA.com](http://NBA.com)",
+ );
+ });
});
diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx
index f28bed72db..f1a912802c 100644
--- a/packages/views/inbox/components/inbox-page.tsx
+++ b/packages/views/inbox/components/inbox-page.tsx
@@ -8,6 +8,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
import {
inboxListOptions,
deduplicateInboxItems,
+ useInboxUnreadCount,
} from "@multica/core/inbox/queries";
import {
useMarkInboxRead,
@@ -105,7 +106,7 @@ export function InboxPage() {
});
const isMobile = useIsMobile();
- const unreadCount = items.filter((i) => !i.read).length;
+ const unreadCount = useInboxUnreadCount(wsId);
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();
@@ -114,14 +115,23 @@ export function InboxPage() {
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
- // Click-to-read: select + auto-mark-read
+ // Auto-mark-read whenever a selected item is unread — covers both click-
+ // to-select and URL-param-select (e.g. OS notification click on desktop).
+ // The mutation flips `read: true` optimistically, so this effect settles
+ // in one pass and can't loop. Kept in a `useEffect` rather than inlined
+ // in handleSelect so URL-driven selection triggers it too.
+ const markReadMutate = markReadMutation.mutate;
+ const selectedId = selected?.id;
+ const selectedRead = selected?.read;
+ useEffect(() => {
+ if (!selectedId || selectedRead) return;
+ markReadMutate(selectedId, {
+ onError: () => toast.error("Failed to mark as read"),
+ });
+ }, [selectedId, selectedRead, markReadMutate]);
+
const handleSelect = (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
- if (!item.read) {
- markReadMutation.mutate(item.id, {
- onError: () => toast.error("Failed to mark as read"),
- });
- }
};
const handleArchive = (id: string) => {
diff --git a/packages/views/issues/components/board-card.tsx b/packages/views/issues/components/board-card.tsx
index 6e1fd0a5e5..5cd23565bb 100644
--- a/packages/views/issues/components/board-card.tsx
+++ b/packages/views/issues/components/board-card.tsx
@@ -14,6 +14,7 @@ import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectListOptions } from "@multica/core/projects/queries";
+import { ProjectIcon } from "../../projects/components/project-icon";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
@@ -21,6 +22,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { ProgressRing } from "./progress-ring";
import type { ChildProgress } from "./list-row";
import { IssueActionsContextMenu } from "../actions";
+import { LabelChip } from "../../labels/label-chip";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -59,6 +61,7 @@ export const BoardCardContent = memo(function BoardCardContent({
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((p) => p.id === issue.project_id) : undefined;
+ const labels = issue.labels ?? [];
const updateIssueMutation = useUpdateIssue();
const handleUpdate = useCallback(
@@ -77,6 +80,7 @@ export const BoardCardContent = memo(function BoardCardContent({
const showDueDate = storeProperties.dueDate && issue.due_date;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
+ const showLabels = storeProperties.labels && labels.length > 0;
return (
@@ -88,8 +92,8 @@ export const BoardCardContent = memo(function BoardCardContent({
{issue.title}
- {/* Sub-issue progress + project */}
- {(showChildProgress || showProject) && (
+ {/* Sub-issue progress + project + labels */}
+ {(showChildProgress || showProject || showLabels) && (
{showChildProgress && (
@@ -101,10 +105,13 @@ export const BoardCardContent = memo(function BoardCardContent({
)}
{showProject && (
- {project!.icon || "📁"}
+
{project!.title}
)}
+ {showLabels && labels.map((label) => (
+
+ ))}
)}
diff --git a/packages/views/issues/components/board-column.tsx b/packages/views/issues/components/board-column.tsx
index d5244d60bd..d425ef40e6 100644
--- a/packages/views/issues/components/board-column.tsx
+++ b/packages/views/issues/components/board-column.tsx
@@ -16,7 +16,7 @@ import {
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { useModalStore } from "@multica/core/modals";
import { useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
-import { StatusIcon } from "./status-icon";
+import { StatusHeading } from "./status-heading";
import { DraggableBoardCard } from "./board-card";
import type { ChildProgress } from "./list-row";
@@ -52,16 +52,7 @@ export function BoardColumn({
return (
- {/* Left: status badge + count */}
-
-
-
- {cfg.label}
-
-
- {totalCount ?? issueIds.length}
-
-
+
{/* Right: add + menu */}
diff --git a/packages/views/issues/components/index.ts b/packages/views/issues/components/index.ts
index 32ed2a466f..9165acc32a 100644
--- a/packages/views/issues/components/index.ts
+++ b/packages/views/issues/components/index.ts
@@ -1,6 +1,7 @@
export { StatusIcon } from "./status-icon";
+export { StatusHeading } from "./status-heading";
export { PriorityIcon } from "./priority-icon";
-export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker } from "./pickers";
+export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker, LabelPicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";
diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx
index ba0d5d5f49..60de4c7c79 100644
--- a/packages/views/issues/components/issue-detail.tsx
+++ b/packages/views/issues/components/issue-detail.tsx
@@ -37,7 +37,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { PropRow } from "../../common/prop-row";
import type { IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
-import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker } from ".";
+import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
import { IssueActionsDropdown, useIssueActions } from "../actions";
import { ProjectPicker } from "../../projects/components/project-picker";
import { CommentCard } from "./comment-card";
@@ -387,6 +387,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
+
+
+
}
diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx
index 96b753fdb2..8d196ba8e0 100644
--- a/packages/views/issues/components/issues-header.tsx
+++ b/packages/views/issues/components/issues-header.tsx
@@ -14,6 +14,7 @@ import {
List,
SignalHigh,
SlidersHorizontal,
+ Tag,
User,
UserMinus,
UserPen,
@@ -49,7 +50,10 @@ import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
+import { labelListOptions } from "@multica/core/labels/queries";
+import { ProjectIcon } from "../../projects/components/project-icon";
import { ActorAvatar } from "../../common/actor-avatar";
+import { LabelChip } from "../../labels/label-chip";
import {
SORT_OPTIONS,
CARD_PROPERTY_OPTIONS,
@@ -93,6 +97,7 @@ function getActiveFilterCount(state: {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
+ labelFilters: string[];
}) {
let count = 0;
if (state.statusFilters.length > 0) count++;
@@ -100,6 +105,7 @@ function getActiveFilterCount(state: {
if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++;
if (state.creatorFilters.length > 0) count++;
if (state.projectFilters.length > 0 || state.includeNoProject) count++;
+ if (state.labelFilters.length > 0) count++;
return count;
}
@@ -110,6 +116,7 @@ function useIssueCounts(allIssues: Issue[]) {
const assignee = new Map
();
const creator = new Map();
const project = new Map();
+ const label = new Map();
let noAssignee = 0;
let noProject = 0;
@@ -132,9 +139,15 @@ function useIssueCounts(allIssues: Issue[]) {
} else {
project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1);
}
+
+ if (issue.labels) {
+ for (const l of issue.labels) {
+ label.set(l.id, (label.get(l.id) ?? 0) + 1);
+ }
+ }
}
- return { status, priority, assignee, creator, noAssignee, project, noProject };
+ return { status, priority, assignee, creator, noAssignee, project, noProject, label };
}, [allIssues]);
}
@@ -353,9 +366,7 @@ function ProjectSubContent({
className={FILTER_ITEM_CLASS}
>
-
- {p.icon || }
-
+
{p.title}
{count > 0 && (
@@ -376,6 +387,70 @@ function ProjectSubContent({
);
}
+// ---------------------------------------------------------------------------
+// Label sub-menu content
+// ---------------------------------------------------------------------------
+
+function LabelSubContent({
+ counts,
+ selected,
+ onToggle,
+}: {
+ counts: Map;
+ selected: string[];
+ onToggle: (labelId: string) => void;
+}) {
+ const [search, setSearch] = useState("");
+ const wsId = useWorkspaceId();
+ const { data: labels = [] } = useQuery(labelListOptions(wsId));
+ const query = search.trim().toLowerCase();
+ const filtered = labels.filter((l) => l.name.toLowerCase().includes(query));
+
+ return (
+ <>
+
+ setSearch(e.target.value)}
+ placeholder="Filter..."
+ className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
+ autoFocus
+ />
+
+
+
+ {filtered.map((l) => {
+ const checked = selected.includes(l.id);
+ const count = counts.get(l.id) ?? 0;
+ return (
+
onToggle(l.id)}
+ className={FILTER_ITEM_CLASS}
+ >
+
+
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+ );
+ })}
+
+ {filtered.length === 0 && (
+
+ {search ? "No results" : "No labels yet"}
+
+ )}
+
+ >
+ );
+}
+
// ---------------------------------------------------------------------------
// IssuesHeader
// ---------------------------------------------------------------------------
@@ -392,6 +467,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
const creatorFilters = useViewStore((s) => s.creatorFilters);
const projectFilters = useViewStore((s) => s.projectFilters);
const includeNoProject = useViewStore((s) => s.includeNoProject);
+ const labelFilters = useViewStore((s) => s.labelFilters);
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const cardProperties = useViewStore((s) => s.cardProperties);
@@ -408,6 +484,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
creatorFilters,
projectFilters,
includeNoProject,
+ labelFilters,
}) > 0;
const sortLabel =
@@ -601,6 +678,26 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
+ {/* Label */}
+
+
+
+ Label
+ {labelFilters.length > 0 && (
+
+ {labelFilters.length}
+
+ )}
+
+
+
+
+
+
{/* Reset */}
{hasActiveFilters && (
<>
diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx
index e91edaf766..54db9e80b8 100644
--- a/packages/views/issues/components/issues-page.test.tsx
+++ b/packages/views/issues/components/issues-page.test.tsx
@@ -106,9 +106,10 @@ const mockViewState = {
creatorFilters: [] as { type: string; id: string }[],
projectFilters: [] as string[],
includeNoProject: false,
+ labelFilters: [] as string[],
sortBy: "position" as const,
sortDirection: "asc" as const,
- cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true },
+ cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),
@@ -118,6 +119,7 @@ const mockViewState = {
toggleCreatorFilter: vi.fn(),
toggleProjectFilter: vi.fn(),
toggleNoProject: vi.fn(),
+ toggleLabelFilter: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),
@@ -130,6 +132,7 @@ const mockViewState = {
vi.mock("@multica/core/issues/stores/view-store", () => ({
useClearFiltersOnWorkspaceChange: () => {},
viewStorePersistOptions: () => ({ name: "test", storage: undefined, partialize: (s: any) => s }),
+ mergeViewStatePersisted: (_p: unknown, c: any) => c,
viewStoreSlice: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
@@ -153,6 +156,7 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
+ { key: "labels", label: "Labels" },
{ key: "childProgress", label: "Sub-issue progress" },
],
}));
diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx
index dc2901b270..6a43f65a0c 100644
--- a/packages/views/issues/components/issues-page.tsx
+++ b/packages/views/issues/components/issues-page.tsx
@@ -37,6 +37,7 @@ export function IssuesPage() {
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
const projectFilters = useIssueViewStore((s) => s.projectFilters);
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
+ const labelFilters = useIssueViewStore((s) => s.labelFilters);
// Clear filter state when switching between workspaces (URL-driven).
useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId);
@@ -55,8 +56,8 @@ export function IssuesPage() {
}, [allIssues, scope]);
const issues = useMemo(
- () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }),
- [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
+ () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters }),
+ [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters],
);
// Fetch sub-issue progress from the backend so counts are accurate
diff --git a/packages/views/issues/components/labels-panel.tsx b/packages/views/issues/components/labels-panel.tsx
new file mode 100644
index 0000000000..ed05471d2d
--- /dev/null
+++ b/packages/views/issues/components/labels-panel.tsx
@@ -0,0 +1,290 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Trash2, Plus, Pencil, Check, X } from "lucide-react";
+import { Input } from "@multica/ui/components/ui/input";
+import { Label as UILabel } from "@multica/ui/components/ui/label";
+import { Button } from "@multica/ui/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction,
+} from "@multica/ui/components/ui/alert-dialog";
+import { toast } from "sonner";
+import { useWorkspaceId } from "@multica/core/hooks";
+import { labelListOptions, useCreateLabel, useUpdateLabel, useDeleteLabel } from "@multica/core/labels";
+import type { Label } from "@multica/core/types";
+import { LabelChip } from "../../labels/label-chip";
+
+/** Default color for brand-new labels. Everything else goes through the native picker. */
+const DEFAULT_COLOR_DEFAULT = "#3b82f6";
+
+/**
+ * Workspace-wide labels management surface. Opened from the Manage labels…
+ * footer in the label picker.
+ */
+export function LabelsPanel() {
+ const wsId = useWorkspaceId();
+ const { data: labels = [], isLoading } = useQuery(labelListOptions(wsId));
+
+ const create = useCreateLabel();
+ const update = useUpdateLabel();
+ const del = useDeleteLabel();
+
+ const [newName, setNewName] = useState("");
+ const [newColor, setNewColor] = useState(DEFAULT_COLOR_DEFAULT);
+
+ const [editingId, setEditingId] = useState(null);
+ const [editName, setEditName] = useState("");
+ const [editColor, setEditColor] = useState("");
+ const [editError, setEditError] = useState("");
+
+ const [pendingDeletion, setPendingDeletion] = useState(null);
+
+ const handleCreate = () => {
+ const name = newName.trim();
+ if (!name) return;
+ create.mutate(
+ { name, color: newColor },
+ {
+ onSuccess: () => {
+ setNewName("");
+ setNewColor(DEFAULT_COLOR_DEFAULT);
+ },
+ onError: (err: unknown) => {
+ toast.error(err instanceof Error ? err.message : "Failed to create label");
+ },
+ },
+ );
+ };
+
+ const startEdit = (label: Label) => {
+ setEditingId(label.id);
+ setEditName(label.name);
+ setEditColor(label.color);
+ setEditError("");
+ };
+
+ const cancelEdit = () => {
+ setEditingId(null);
+ setEditName("");
+ setEditColor("");
+ setEditError("");
+ };
+
+ const saveEdit = (id: string) => {
+ const name = editName.trim();
+ if (!name) {
+ // Surface the reason the save didn't happen — previously this was a
+ // silent no-op. Button is also disabled (below) but a visible message
+ // beats a greyed-out button for telling the user WHY.
+ setEditError("Label name is required.");
+ return;
+ }
+ setEditError("");
+ update.mutate(
+ { id, name, color: editColor },
+ {
+ onSuccess: cancelEdit,
+ onError: (err: unknown) => {
+ toast.error(err instanceof Error ? err.message : "Failed to update label");
+ },
+ },
+ );
+ };
+
+ return (
+
+
+ Create and manage labels to categorize issues across your workspace.
+
+
+ {/* Create form — color swatch, name, Add button all in one row */}
+
+
+
setNewName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreate();
+ }}
+ className="flex-1"
+ maxLength={32}
+ aria-label="New label name"
+ />
+
+
+ Add
+
+
+
+ {/* List — scrolls when labels exceed viewport */}
+
+ {isLoading &&
Loading…
}
+ {!isLoading && labels.length === 0 && (
+
No labels yet.
+ )}
+ {labels.map((label) => {
+ const isEditing = editingId === label.id;
+ const editNameEmpty = isEditing && !editName.trim();
+ return (
+
+
+ {isEditing ? (
+ <>
+
{
+ setEditName(e.target.value);
+ if (e.target.value.trim()) setEditError("");
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") saveEdit(label.id);
+ if (e.key === "Escape") cancelEdit();
+ }}
+ className="flex-1 h-8"
+ maxLength={32}
+ autoFocus
+ aria-invalid={editNameEmpty}
+ aria-describedby={editError ? `label-edit-error-${label.id}` : undefined}
+ />
+
+
saveEdit(label.id)}
+ disabled={editNameEmpty || update.isPending}
+ aria-label="Save"
+ >
+
+
+
+
+
+ >
+ ) : (
+ <>
+ {/* min-w-0 on the label wrapper lets long names wrap without
+ pushing the hex/buttons off the right edge. */}
+
+
+
+ {label.color}
+
+
+
startEdit(label)}
+ aria-label={`Edit ${label.name}`}
+ >
+
+
+
setPendingDeletion(label)}
+ aria-label={`Delete ${label.name}`}
+ >
+
+
+ >
+ )}
+
+ {isEditing && editError && (
+
+ {editError}
+
+ )}
+
+ );
+ })}
+
+
+
!o && setPendingDeletion(null)}>
+
+
+ Delete label?
+
+ The label {pendingDeletion?.name} will be removed from all
+ issues. This cannot be undone.
+
+
+
+ Cancel
+ {
+ if (!pendingDeletion) return;
+ del.mutate(pendingDeletion.id, {
+ onSuccess: () => setPendingDeletion(null),
+ onError: (err: unknown) => {
+ toast.error(err instanceof Error ? err.message : "Failed to delete label");
+ },
+ });
+ }}
+ >
+ Delete
+
+
+
+
+
+ );
+}
+
+/**
+ * Color picker — a single swatch that opens the browser's native color
+ * picker. Full gamut, trusted UX, zero visual clutter. `focus-within` ring
+ * makes keyboard focus visible despite the transparent ` `.
+ */
+function ColorPalette({
+ value,
+ onChange,
+ compact,
+}: {
+ value: string;
+ onChange: (c: string) => void;
+ compact?: boolean;
+}) {
+ const size = compact ? "h-7 w-7" : "h-9 w-9";
+ return (
+
+ {!compact && Color }
+
+ onChange(e.target.value)}
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
+ />
+
+ {!compact && (
+ {value}
+ )}
+
+ );
+}
diff --git a/packages/views/issues/components/list-row.tsx b/packages/views/issues/components/list-row.tsx
index bb1c8c99af..9f29c8cd02 100644
--- a/packages/views/issues/components/list-row.tsx
+++ b/packages/views/issues/components/list-row.tsx
@@ -10,9 +10,11 @@ import { useWorkspacePaths } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { projectListOptions } from "@multica/core/projects/queries";
+import { ProjectIcon } from "../../projects/components/project-icon";
import { PriorityIcon } from "./priority-icon";
import { ProgressRing } from "./progress-ring";
import { IssueActionsContextMenu } from "../actions";
+import { LabelChip } from "../../labels/label-chip";
export interface ChildProgress {
done: number;
@@ -43,11 +45,13 @@ export const ListRow = memo(function ListRow({
enabled: storeProperties.project && !!issue.project_id,
});
const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined;
+ const labels = issue.labels ?? [];
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
+ const showLabels = storeProperties.labels && labels.length > 0;
return (
@@ -87,10 +91,22 @@ export const ListRow = memo(function ListRow({
)}
+ {showLabels && (
+
+ {labels.slice(0, 3).map((label) => (
+
+ ))}
+ {labels.length > 3 && (
+
+ +{labels.length - 3}
+
+ )}
+
+ )}
{showProject && (
- {project!.icon || "📁"}
+
{project!.title}
)}
diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx
index 0cef6e4ce3..d4d8f00bd0 100644
--- a/packages/views/issues/components/list-view.tsx
+++ b/packages/views/issues/components/list-view.tsx
@@ -8,12 +8,11 @@ import { Button } from "@multica/ui/components/ui/button";
import type { Issue, IssueStatus } from "@multica/core/types";
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { MyIssuesFilter } from "@multica/core/issues/queries";
-import { STATUS_CONFIG } from "@multica/core/issues/config";
import { useModalStore } from "@multica/core/modals";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { sortIssues } from "../utils/sort";
-import { StatusIcon } from "./status-icon";
+import { StatusHeading } from "./status-heading";
import { ListRow, type ChildProgress } from "./list-row";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
@@ -104,7 +103,6 @@ function StatusAccordionItem({
childProgressMap: Map;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
- const cfg = STATUS_CONFIG[status];
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
const select = useIssueSelectionStore((s) => s.select);
const deselect = useIssueSelectionStore((s) => s.deselect);
@@ -140,11 +138,7 @@ function StatusAccordionItem({
-
-
- {cfg.label}
-
- {total}
+
diff --git a/packages/views/issues/components/pickers/index.ts b/packages/views/issues/components/pickers/index.ts
index 3b5876bc91..28622d75d8 100644
--- a/packages/views/issues/components/pickers/index.ts
+++ b/packages/views/issues/components/pickers/index.ts
@@ -3,3 +3,4 @@ export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
export { DueDatePicker } from "./due-date-picker";
+export { LabelPicker } from "./label-picker";
diff --git a/packages/views/issues/components/pickers/label-picker.tsx b/packages/views/issues/components/pickers/label-picker.tsx
new file mode 100644
index 0000000000..55fc0e2556
--- /dev/null
+++ b/packages/views/issues/components/pickers/label-picker.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import { useMemo, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Tag, Plus, Settings2 } from "lucide-react";
+import { toast } from "sonner";
+import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
+import { useWorkspaceId } from "@multica/core/hooks";
+import {
+ labelListOptions,
+ issueLabelsOptions,
+ useAttachLabel,
+ useDetachLabel,
+ useCreateLabel,
+} from "@multica/core/labels";
+import { LabelChip } from "../../../labels/label-chip";
+import { LabelsPanel } from "../labels-panel";
+import {
+ PropertyPicker,
+ PickerItem,
+ PickerEmpty,
+} from "./property-picker";
+
+interface LabelPickerProps {
+ issueId: string;
+ /** Optional controlled open state (for tests / cmd+k integration). */
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ align?: "start" | "center" | "end";
+}
+
+/**
+ * Palette of colors used when creating a label inline from the picker.
+ * We cycle by hash(name) so the same name always gets the same color,
+ * and a color can still be changed afterwards from the Manage dialog.
+ */
+const INLINE_COLORS = [
+ "#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6",
+ "#3b82f6", "#6366f1", "#a855f7", "#ec4899", "#64748b",
+] as const;
+
+function pickInlineColor(name: string): string {
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
+ }
+ return INLINE_COLORS[hash % INLINE_COLORS.length] ?? INLINE_COLORS[0]!;
+}
+
+/**
+ * Multi-select label picker for an issue. Shows currently-attached labels
+ * as inline chips above the trigger and lets the user toggle any label in
+ * the workspace. Attach/detach are optimistic — the UI updates before the
+ * server confirms.
+ *
+ * When the search term has no matches, offers inline creation: typing a
+ * new name and pressing Enter (or clicking the "Create X" row) creates the
+ * label with a hash-derived color and attaches it in one motion.
+ *
+ * A "Manage labels" item at the bottom opens a dialog with the full
+ * workspace label management panel (rename, recolor, delete) — keeping
+ * users in context without forcing them to navigate away.
+ */
+export function LabelPicker({
+ issueId,
+ open: controlledOpen,
+ onOpenChange,
+ align = "start",
+}: LabelPickerProps) {
+ const [internalOpen, setInternalOpen] = useState(false);
+ const open = controlledOpen ?? internalOpen;
+ const setOpen = onOpenChange ?? setInternalOpen;
+ const [filter, setFilter] = useState("");
+ const [manageOpen, setManageOpen] = useState(false);
+
+ // Synchronous lock to prevent double-submit on rapid Enter / click. React
+ // state (create.isPending, filter) isn't visible until the next render, so
+ // two events within the same tick can both pass the canCreate guard and
+ // fire two create.mutate calls — the second hits 409 and shows a red toast
+ // for an error the user didn't cause. A ref closes the window cleanly.
+ const creatingRef = useRef(false);
+
+ const wsId = useWorkspaceId();
+ const { data: allLabels = [] } = useQuery(labelListOptions(wsId));
+ const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, issueId));
+
+ const attach = useAttachLabel(issueId);
+ const detach = useDetachLabel(issueId);
+ const create = useCreateLabel();
+
+ const attachedIds = useMemo(
+ () => new Set(attachedLabels.map((l) => l.id)),
+ [attachedLabels],
+ );
+
+ const query = filter.trim();
+ const queryLower = query.toLowerCase();
+ const filtered = allLabels.filter((l) => l.name.toLowerCase().includes(queryLower));
+ const exactMatch = allLabels.some((l) => l.name.toLowerCase() === queryLower);
+ const canCreate = query.length > 0 && !exactMatch && !create.isPending;
+
+ const toggle = (labelId: string) => {
+ if (attachedIds.has(labelId)) {
+ detach.mutate(labelId);
+ } else {
+ attach.mutate(labelId);
+ }
+ };
+
+ const createAndAttach = () => {
+ if (!canCreate || creatingRef.current) return;
+ creatingRef.current = true;
+ const name = query;
+ create.mutate(
+ { name, color: pickInlineColor(name) },
+ {
+ onSuccess: (label) => {
+ attach.mutate(label.id);
+ setFilter("");
+ },
+ onError: (err: unknown) => {
+ toast.error(err instanceof Error ? err.message : "Failed to create label");
+ },
+ onSettled: () => {
+ creatingRef.current = false;
+ },
+ },
+ );
+ };
+
+ const openManage = () => {
+ setOpen(false);
+ setManageOpen(true);
+ };
+
+ const hasLabels = attachedLabels.length > 0;
+
+ return (
+
+
{
+ setOpen(v);
+ if (!v) setFilter("");
+ }}
+ width="w-80"
+ align={align}
+ searchable
+ searchPlaceholder="Find or create a label…"
+ onSearchChange={setFilter}
+ triggerRender={
+ hasLabels ? (
+
+ ) : undefined
+ }
+ trigger={
+ hasLabels ? (
+ <>
+ {attachedLabels.map((l) => (
+ detach.mutate(l.id)}
+ />
+ ))}
+ >
+ ) : (
+ <>
+
+ Add label
+ >
+ )
+ }
+ footer={
+ // Rendered outside the arrow-key listbox so keyboard nav doesn't
+ // treat "Manage labels…" as another label option.
+
+
+ Manage labels…
+
+ }
+ >
+ {filtered.map((label) => {
+ const selected = attachedIds.has(label.id);
+ return (
+ toggle(label.id)}
+ >
+
+ {label.name}
+
+ );
+ })}
+ {filtered.length === 0 && !canCreate && }
+ {canCreate && (
+
+
+
+ Create “{query}”
+
+
+
+ )}
+
+
+
+
+ Manage labels
+
+
+
+
+ );
+}
diff --git a/packages/views/issues/components/pickers/property-picker.tsx b/packages/views/issues/components/pickers/property-picker.tsx
index d03045e5dd..8dbd926015 100644
--- a/packages/views/issues/components/pickers/property-picker.tsx
+++ b/packages/views/issues/components/pickers/property-picker.tsx
@@ -33,6 +33,7 @@ export function PropertyPicker({
header,
tooltip,
children,
+ footer,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
@@ -53,6 +54,13 @@ export function PropertyPicker({
* open (otherwise tooltip + popover would stack on the same anchor). */
tooltip?: React.ReactNode;
children: React.ReactNode;
+ /**
+ * Optional footer rendered below the listbox. Unlike items rendered as
+ * children, the footer is *not* included in arrow-key navigation — use it
+ * for actions like "Create new…" or "Manage…" that shouldn't be treated as
+ * selectable listbox options.
+ */
+ footer?: React.ReactNode;
}) {
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
@@ -164,6 +172,7 @@ export function PropertyPicker({
)}
{header && {header}
}
{children}
+ {footer && {footer}
}
);
@@ -200,10 +209,13 @@ export function PickerItem({
onClick={onClick}
className={`flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}
>
+ {/* min-w-0 lets long children (like truncated label names) shrink
+ inside the flex row instead of pushing the selected checkmark off
+ the right edge. The check column always reserves its 14px slot
+ (visible when selected, invisible otherwise) so unselected rows
+ align with selected rows and the eye doesn't chase a jittery
+ right edge. */}
{children}
- {/* Check column always reserves its 14px slot (visible when selected,
- invisible otherwise) so unselected rows align with selected rows
- and the eye doesn't have to chase a jittery right edge. */}
+
+
+ {cfg.label}
+
+ {count}
+
+ );
+}
diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts
index adbb4faae7..bbf8913fa1 100644
--- a/packages/views/issues/utils/filter.test.ts
+++ b/packages/views/issues/utils/filter.test.ts
@@ -10,6 +10,7 @@ const NO_FILTER: IssueFilters = {
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
+ labelFilters: [],
};
function makeIssue(overrides: Partial
= {}): Issue {
@@ -161,4 +162,46 @@ describe("filterIssues", () => {
});
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
});
+
+ // --- Label ---
+ // Build a separate fixture for label tests so we can sprinkle labels onto
+ // specific rows without polluting the assignee/project test cases above.
+ const makeLabel = (id: string, name: string, color: string) => ({
+ id,
+ name,
+ color,
+ workspace_id: "ws-1",
+ created_at: "2025-01-01T00:00:00Z",
+ updated_at: "2025-01-01T00:00:00Z",
+ });
+ const labelBug = makeLabel("lab-bug", "bug", "#ff0000");
+ const labelFeat = makeLabel("lab-feat", "feature", "#00ff00");
+ const labelP0 = makeLabel("lab-p0", "p0", "#0000ff");
+ const labeledIssues: Issue[] = [
+ makeIssue({ id: "L1", labels: [labelBug] }),
+ makeIssue({ id: "L2", labels: [labelFeat] }),
+ makeIssue({ id: "L3", labels: [labelBug, labelP0] }),
+ makeIssue({ id: "L4", labels: [] }),
+ makeIssue({ id: "L5" }), // labels field absent
+ ];
+
+ it("filters by a single label", () => {
+ const result = filterIssues(labeledIssues, { ...NO_FILTER, labelFilters: ["lab-bug"] });
+ expect(result.map((i) => i.id)).toEqual(["L1", "L3"]);
+ });
+
+ it("filters by multiple labels with OR semantics", () => {
+ const result = filterIssues(labeledIssues, {
+ ...NO_FILTER,
+ labelFilters: ["lab-bug", "lab-feat"],
+ });
+ expect(result.map((i) => i.id)).toEqual(["L1", "L2", "L3"]);
+ });
+
+ it("excludes issues with no labels when a label filter is active", () => {
+ const result = filterIssues(labeledIssues, { ...NO_FILTER, labelFilters: ["lab-bug"] });
+ // L4 (empty labels) and L5 (missing labels field) must both be filtered out.
+ expect(result.map((i) => i.id)).not.toContain("L4");
+ expect(result.map((i) => i.id)).not.toContain("L5");
+ });
});
diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts
index 7779318ce9..5d03081223 100644
--- a/packages/views/issues/utils/filter.ts
+++ b/packages/views/issues/utils/filter.ts
@@ -9,6 +9,7 @@ export interface IssueFilters {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
+ labelFilters: string[];
}
/**
@@ -21,7 +22,7 @@ export interface IssueFilters {
* - When both → show matching assignees + unassigned
*/
export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
- const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters;
+ const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters } = filters;
const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee;
const hasProjectFilter = projectFilters.length > 0 || includeNoProject;
@@ -67,6 +68,14 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
}
}
+ if (labelFilters.length > 0) {
+ // OR semantics within the filter: keep issues that carry any of the
+ // selected labels. Matches existing priority / project multi-select.
+ const issueLabels = issue.labels;
+ if (!issueLabels || issueLabels.length === 0) return false;
+ if (!issueLabels.some((l) => labelFilters.includes(l.id))) return false;
+ }
+
return true;
});
}
diff --git a/packages/views/labels/index.ts b/packages/views/labels/index.ts
new file mode 100644
index 0000000000..e181d794f8
--- /dev/null
+++ b/packages/views/labels/index.ts
@@ -0,0 +1 @@
+export { LabelChip } from "./label-chip";
diff --git a/packages/views/labels/label-chip.tsx b/packages/views/labels/label-chip.tsx
new file mode 100644
index 0000000000..7e4c3c2a21
--- /dev/null
+++ b/packages/views/labels/label-chip.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import type { Label } from "@multica/core/types";
+import { X } from "lucide-react";
+
+/**
+ * Map a label's `#rrggbb` color to a readable text color.
+ *
+ * Uses the ITU-R BT.601 perceived-luminance formula: colors above the
+ * threshold get dark text (#111827), colors below get light text (#f9fafb).
+ * This works for both pastel and saturated palettes without a hard lookup
+ * table.
+ *
+ * The malformed-hex fallback returns dark-on-default which is readable on
+ * the default `backgroundColor` rendering path — better than pure black
+ * which disappears on dark chips.
+ *
+ * SECURITY INVARIANT: `LabelChip` applies `style={{ backgroundColor: color }}`
+ * directly, trusting the backend's color format. The backend's
+ * `normalizeColor` regex pins the value to `^#?[0-9a-fA-F]{6}$`. If that regex
+ * ever loosens (named colors, `url(...)`, etc.), this becomes an injection
+ * vector.
+ */
+function contrastTextColor(hex: string): string {
+ const h = hex.replace("#", "");
+ if (h.length !== 6) return "#111827";
+ const r = parseInt(h.slice(0, 2), 16) / 255;
+ const g = parseInt(h.slice(2, 4), 16) / 255;
+ const b = parseInt(h.slice(4, 6), 16) / 255;
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
+ return luminance > 0.55 ? "#111827" : "#f9fafb";
+}
+
+interface LabelChipProps {
+ label: Label;
+ onRemove?: () => void;
+ className?: string;
+ /**
+ * When true, show the full label name without truncation. Use this in
+ * management/edit surfaces where users need to see or verify the exact
+ * name. The default (false) truncates at 12rem to keep chips compact in
+ * the issue sidebar and future board/list card rows.
+ */
+ fullName?: boolean;
+}
+
+/**
+ * Renders a single label as a colored pill. If `onRemove` is provided, shows
+ * an × button that calls it. Used in the issue-detail sidebar, the picker,
+ * and the management dialog.
+ */
+export function LabelChip({ label, onRemove, className, fullName }: LabelChipProps) {
+ const textColor = contrastTextColor(label.color);
+ const nameClass = fullName ? "break-all" : "truncate max-w-[12rem]";
+ return (
+
+ {label.name}
+ {onRemove && (
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ // bg-current/20 uses the computed text color so the hover state is
+ // visible on both light and dark chip backgrounds. hover:bg-black/10
+ // was invisible on darker chips (anything requiring light text).
+ className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full hover:bg-current/20 focus:outline-none focus:ring-1 focus:ring-current"
+ aria-label={`Remove label ${label.name}`}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx
index 751b95e593..d2f4750141 100644
--- a/packages/views/layout/app-sidebar.tsx
+++ b/packages/views/layout/app-sidebar.tsx
@@ -29,8 +29,6 @@ import {
SquarePen,
CircleUser,
FolderKanban,
- MessageSquare,
- Loader2,
X,
Zap,
} from "lucide-react";
@@ -67,8 +65,6 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
-import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
-import { useAnchorTracker } from "../chat/components/context-anchor";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
@@ -78,6 +74,15 @@ import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
+import { ProjectIcon } from "../projects/components/project-icon";
+
+// Top-level nav items stay active when the user is on a child route
+// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep
+// strict equality elsewhere — a pinned project shouldn't highlight on
+// sub-pages of itself.
+function isNavActive(pathname: string, href: string): boolean {
+ return pathname === href || pathname.startsWith(href + "/");
+}
// Stable empty arrays for query defaults. Using an inline `= []` default on
// `useQuery` creates a new array reference on every render when `data` is
@@ -88,14 +93,12 @@ const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited> = [];
const EMPTY_INVITATIONS: Awaited> = [];
const EMPTY_INBOX: Awaited> = [];
-const EMPTY_CHAT_SESSIONS: Awaited> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
type NavKey =
| "inbox"
- | "chat"
| "myIssues"
| "issues"
| "projects"
@@ -107,7 +110,6 @@ type NavKey =
const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
{ key: "inbox", label: "Inbox", icon: Inbox },
- { key: "chat", label: "Chat", icon: MessageSquare },
{ key: "myIssues", label: "My Issues", icon: CircleUser },
];
@@ -269,11 +271,7 @@ function PinRow({
if (projectQuery.isPending) return ;
if (projectQuery.isError || !projectQuery.data) return null;
const project = projectQuery.data;
- const iconNode = (
-
- {project.icon || "📁"}
-
- );
+ const iconNode = ;
return (
deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
- const { data: chatSessions = EMPTY_CHAT_SESSIONS } = useQuery({
- ...chatSessionsOptions(wsId ?? ""),
- enabled: !!wsId,
- });
- const hasChatUnread = React.useMemo(
- () => chatSessions.some((s) => s.has_unread),
- [chatSessions],
- );
- const { data: pendingChatTasks } = useQuery({
- ...pendingChatTasksOptions(wsId ?? ""),
- enabled: !!wsId,
- });
- const hasChatRunning = (pendingChatTasks?.tasks.length ?? 0) > 0;
- // Track last anchor-eligible route so the Chat page (which is its own route)
- // can still resolve focus-mode context from the page the user was just on.
- useAnchorTracker();
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
@@ -447,7 +429,12 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
-
+
+
+ {myInvitations.length > 0 && (
+
+ )}
+
{workspace?.name ?? "Multica"}
@@ -583,7 +570,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
{personalNav.map((item) => {
const href = p[item.key]();
- const isActive = pathname === href;
+ const isActive = isNavActive(pathname, href);
return (
99 ? "99+" : unreadCount}
)}
- {item.label === "Chat" && hasChatRunning && (
-
- )}
- {item.label === "Chat" && !hasChatRunning && hasChatUnread && (
-
- )}
);
@@ -653,7 +634,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
{workspaceNav.map((item) => {
const href = p[item.key]();
- const isActive = pathname === href;
+ const isActive = isNavActive(pathname, href);
return (
{configureNav.map((item) => {
const href = p[item.key]();
- const isActive = pathname === href;
+ const isActive = isNavActive(pathname, href);
return (
vi.fn());
const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
+const mockSetLastAssignee = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
@@ -22,8 +23,11 @@ const mockDraftStore = {
assigneeId: undefined,
dueDate: null,
},
+ lastAssigneeType: undefined,
+ lastAssigneeId: undefined,
setDraft: mockSetDraft,
clearDraft: mockClearDraft,
+ setLastAssignee: mockSetLastAssignee,
};
vi.mock("../navigation", () => ({
@@ -238,6 +242,7 @@ describe("CreateIssueModal", () => {
});
});
+ expect(mockSetLastAssignee).toHaveBeenCalledWith(undefined, undefined);
expect(mockClearDraft).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockToastCustom).toHaveBeenCalledTimes(1);
diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx
index 824bc4ba8c..4cc5e40d16 100644
--- a/packages/views/modals/create-issue.tsx
+++ b/packages/views/modals/create-issue.tsx
@@ -57,6 +57,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const draft = useIssueDraftStore((s) => s.draft);
const setDraft = useIssueDraftStore((s) => s.setDraft);
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
+ const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
const [title, setTitle] = useState(draft.title);
const descEditorRef = useRef(null);
@@ -153,6 +154,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
}
}
+ setLastAssignee(assigneeType, assigneeId);
clearDraft();
const shouldShowBacklogHint =
status === "backlog" && assigneeType === "agent" && assigneeId &&
diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx
index 03c9b2af13..984186a84c 100644
--- a/packages/views/my-issues/components/my-issues-page.tsx
+++ b/packages/views/my-issues/components/my-issues-page.tsx
@@ -82,6 +82,7 @@ export function MyIssuesPage() {
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
+ labelFilters: [],
}),
[myIssues, statusFilters, priorityFilters],
);
diff --git a/packages/views/onboarding/steps/step-welcome.tsx b/packages/views/onboarding/steps/step-welcome.tsx
index 6df3dd1020..d47013c55b 100644
--- a/packages/views/onboarding/steps/step-welcome.tsx
+++ b/packages/views/onboarding/steps/step-welcome.tsx
@@ -265,6 +265,8 @@ type ProviderName =
| "opencode"
| "openclaw"
| "hermes"
+ | "kimi"
+ | "kiro"
| "pi"
| "copilot"
| "cursor";
diff --git a/packages/views/package.json b/packages/views/package.json
index 2033d8e489..68abbcf312 100644
--- a/packages/views/package.json
+++ b/packages/views/package.json
@@ -78,6 +78,7 @@
"recharts": "3.8.0",
"rehype-katex": "catalog:",
"rehype-raw": "^7.0.0",
+ "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "catalog:",
"sonner": "^2.0.7"
diff --git a/packages/views/platform/index.ts b/packages/views/platform/index.ts
index 9633eadea2..6ae77075b7 100644
--- a/packages/views/platform/index.ts
+++ b/packages/views/platform/index.ts
@@ -1,3 +1,4 @@
export { useImmersiveMode } from "./use-immersive-mode";
+export { useDesktopUnreadBadge } from "./use-desktop-unread-badge";
export { DragStrip } from "./drag-strip";
export { openExternal } from "./open-external";
diff --git a/packages/views/platform/use-desktop-unread-badge.ts b/packages/views/platform/use-desktop-unread-badge.ts
new file mode 100644
index 0000000000..91801390e9
--- /dev/null
+++ b/packages/views/platform/use-desktop-unread-badge.ts
@@ -0,0 +1,23 @@
+import { useEffect } from "react";
+import { useInboxUnreadCount } from "@multica/core/inbox/queries";
+
+type BadgeCapableAPI = {
+ setUnreadBadge?: (count: number) => void;
+};
+
+function getDesktopAPI(): BadgeCapableAPI | undefined {
+ if (typeof window === "undefined") return undefined;
+ return (window as unknown as { desktopAPI?: BadgeCapableAPI }).desktopAPI;
+}
+
+/**
+ * Mirror the inbox unread count onto the OS dock/taskbar badge. No-op on web
+ * (no `desktopAPI`) and on the login screen (no workspace ⇒ count defaults
+ * to 0, which clears any stale badge from a previous session).
+ */
+export function useDesktopUnreadBadge(wsId: string | null | undefined): void {
+ const count = useInboxUnreadCount(wsId);
+ useEffect(() => {
+ getDesktopAPI()?.setUnreadBadge?.(count);
+ }, [count]);
+}
diff --git a/packages/views/projects/components/project-chip.tsx b/packages/views/projects/components/project-chip.tsx
index aaa79f428d..34285ce556 100644
--- a/packages/views/projects/components/project-chip.tsx
+++ b/packages/views/projects/components/project-chip.tsx
@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { projectListOptions, projectDetailOptions } from "@multica/core/projects/queries";
import { useWorkspaceId } from "@multica/core/hooks";
+import { ProjectIcon } from "./project-icon";
/**
* Compact presentational representation of a project —
@@ -10,9 +11,6 @@ import { useWorkspaceId } from "@multica/core/hooks";
*
* Not a link / button: callers wrap it in whatever interactive shell they
* need. Pure UI — data is queried internally so callers can pass just an id.
- *
- * `📁` matches the fallback used elsewhere (project-picker, projects-page,
- * project-detail) so project affordances feel consistent across the app.
*/
export interface ProjectChipProps {
projectId: string;
@@ -45,7 +43,7 @@ export function ProjectChip({
if (!project) {
return (
- 📁
+
{fallbackLabel ?? "Project"}
@@ -55,7 +53,7 @@ export function ProjectChip({
return (
- {project.icon || "📁"}
+
{project.title}
);
diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx
index ceb14204b8..ce432e21f1 100644
--- a/packages/views/projects/components/project-detail.tsx
+++ b/packages/views/projects/components/project-detail.tsx
@@ -110,10 +110,11 @@ function ProjectIssuesContent({
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
const creatorFilters = useViewStore((s) => s.creatorFilters);
+ const labelFilters = useViewStore((s) => s.labelFilters);
const issues = useMemo(
- () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }),
- [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
+ () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }),
+ [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters],
);
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
diff --git a/packages/views/projects/components/project-icon.tsx b/packages/views/projects/components/project-icon.tsx
new file mode 100644
index 0000000000..a07e9ec9c2
--- /dev/null
+++ b/packages/views/projects/components/project-icon.tsx
@@ -0,0 +1,31 @@
+import type { Project } from "@multica/core/types";
+import { cn } from "@multica/ui/lib/utils";
+
+export type ProjectIconSize = "sm" | "md" | "lg";
+
+export interface ProjectIconProps {
+ project?: Pick | null;
+ size?: ProjectIconSize;
+ className?: string;
+}
+
+const SIZE_CLASS: Record = {
+ sm: "size-3.5 text-xs leading-none",
+ md: "size-4 text-sm leading-none",
+ lg: "size-6 text-2xl leading-none",
+};
+
+export function ProjectIcon({ project, size = "sm", className }: ProjectIconProps) {
+ return (
+
+ {project?.icon || "📁"}
+
+ );
+}
diff --git a/packages/views/projects/components/project-picker.tsx b/packages/views/projects/components/project-picker.tsx
index 1dff9c1761..6147c12d0e 100644
--- a/packages/views/projects/components/project-picker.tsx
+++ b/packages/views/projects/components/project-picker.tsx
@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
+import { ProjectIcon } from "./project-icon";
export function ProjectPicker({
projectId,
@@ -34,13 +35,17 @@ export function ProjectPicker({
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
-
+ {current ? (
+
+ ) : (
+
+ )}
{current ? current.title : "No project"}
{projects.map((p) => (
onUpdate({ project_id: p.id })}>
- {p.icon || "📁"}
+
{p.title}
{p.id === projectId && }
diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx
index 35462de160..1c27455ad6 100644
--- a/packages/views/projects/components/projects-page.tsx
+++ b/packages/views/projects/components/projects-page.tsx
@@ -36,6 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
import { PageHeader } from "../../layout/page-header";
import { PriorityIcon } from "../../issues/components/priority-icon";
+import { ProjectIcon } from "./project-icon";
function formatRelativeDate(date: string): string {
const diff = Date.now() - new Date(date).getTime();
@@ -77,7 +78,7 @@ function ProjectRow({ project }: { project: Project }) {
href={wsPaths.projectDetail(project.id)}
className="flex min-w-0 flex-1 items-center gap-2"
>
- {project.icon || "📁"}
+
{project.title}
diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx
index ab449bd2bc..4f22cdc4ae 100644
--- a/packages/views/runtimes/components/provider-logo.tsx
+++ b/packages/views/runtimes/components/provider-logo.tsx
@@ -1,3 +1,4 @@
+import { useId } from "react";
import { Monitor } from "lucide-react";
// Claude (Anthropic) — official mark, sourced from Bootstrap Icons (bi-claude)
@@ -125,6 +126,45 @@ function KimiLogo({ className }: { className: string }) {
);
}
+// Kiro CLI — official icon sourced from kiro.dev/icon.svg.
+function KiroLogo({ className }: { className: string }) {
+ const maskId = `kiro-logo-mask-${useId().replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
export function ProviderLogo({
provider,
className = "h-4 w-4",
@@ -151,6 +191,8 @@ export function ProviderLogo({
return ;
case "kimi":
return ;
+ case "kiro":
+ return ;
default:
return ;
}
diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx
index 97c6cc0d56..ba63b624ec 100644
--- a/packages/views/search/search-command.tsx
+++ b/packages/views/search/search-command.tsx
@@ -36,6 +36,7 @@ import type { WorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { StatusIcon } from "../issues/components";
+import { ProjectIcon } from "../projects/components/project-icon";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import type { ProjectStatus } from "@multica/core/types";
@@ -573,9 +574,7 @@ export function SearchCommand() {
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
>
-
- {project.icon || }
-
+
diff --git a/packages/views/settings/components/labs-tab.tsx b/packages/views/settings/components/labs-tab.tsx
new file mode 100644
index 0000000000..f91e9cdc2a
--- /dev/null
+++ b/packages/views/settings/components/labs-tab.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { FlaskConical } from "lucide-react";
+import { Card, CardContent } from "@multica/ui/components/ui/card";
+
+export function LabsTab() {
+ return (
+
+
+ Labs
+
+
+
+
+
+
+
+
+
No experimental features yet
+
+ Beta features that require manual opt-in will appear here.
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/views/settings/components/settings-page.tsx b/packages/views/settings/components/settings-page.tsx
index 9ce7b392ed..175315d894 100644
--- a/packages/views/settings/components/settings-page.tsx
+++ b/packages/views/settings/components/settings-page.tsx
@@ -1,7 +1,7 @@
"use client";
import React from "react";
-import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
+import { User, Palette, Key, Settings, Users, FolderGit2, FlaskConical } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useCurrentWorkspace } from "@multica/core/paths";
import { AccountTab } from "./account-tab";
@@ -10,6 +10,7 @@ import { TokensTab } from "./tokens-tab";
import { WorkspaceTab } from "./workspace-tab";
import { MembersTab } from "./members-tab";
import { RepositoriesTab } from "./repositories-tab";
+import { LabsTab } from "./labs-tab";
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
@@ -20,6 +21,7 @@ const accountTabs = [
const workspaceTabs = [
{ value: "workspace", label: "General", icon: Settings },
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
+ { value: "labs", label: "Labs", icon: FlaskConical },
{ value: "members", label: "Members", icon: Users },
];
@@ -82,6 +84,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
+
{extraAccountTabs?.map((tab) => (
{tab.content}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52bf6baa90..8dc131c8ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -582,6 +582,9 @@ importers:
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
+ remark-breaks:
+ specifier: ^4.0.0
+ version: 4.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -742,6 +745,9 @@ importers:
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
+ remark-breaks:
+ specifier: ^4.0.0
+ version: 4.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -5459,6 +5465,9 @@ packages:
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+ mdast-util-newline-to-break@2.0.0:
+ resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
+
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -6447,6 +6456,9 @@ packages:
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+ remark-breaks@4.0.0:
+ resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
+
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -12618,6 +12630,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ mdast-util-newline-to-break@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-find-and-replace: 3.0.2
+
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -13957,6 +13974,12 @@ snapshots:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
+ remark-breaks@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-newline-to-break: 2.0.0
+ unified: 11.0.5
+
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh
index b02ebe0f31..095e21a38d 100644
--- a/scripts/init-worktree-env.sh
+++ b/scripts/init-worktree-env.sh
@@ -32,6 +32,7 @@ DATABASE_URL=postgres://multica:multica@localhost:${postgres_port}/${postgres_db
PORT=${backend_port}
JWT_SECRET=change-me-in-production
+MULTICA_DEV_VERIFICATION_CODE=
MULTICA_SERVER_URL=ws://localhost:${backend_port}/ws
MULTICA_APP_URL=${frontend_origin}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 02c1560f9e..c4cab72579 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -88,6 +88,78 @@ function Pull-OfficialSelfHostImages {
exit 1
}
+function Convert-ToCliArch {
+ param([object]$Value)
+
+ if ($null -eq $Value) {
+ return $null
+ }
+
+ $normalized = "$Value".Trim().ToUpperInvariant()
+ switch ($normalized) {
+ "9" { return "amd64" }
+ "AMD64" { return "amd64" }
+ "X64" { return "amd64" }
+ "X86_64" { return "amd64" }
+ "12" { return "arm64" }
+ "ARM64" { return "arm64" }
+ "AARCH64" { return "arm64" }
+ default { return $null }
+ }
+}
+
+function Get-WindowsCliArch {
+ $signals = @()
+ $nativeArchSignalFound = $false
+
+ # Prefer the native processor architecture over the current PowerShell
+ # process architecture. This keeps Windows on ARM from being misdetected
+ # when PowerShell is running through x64/x86 emulation.
+ try {
+ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) {
+ $processorArch = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop |
+ Select-Object -First 1 -ExpandProperty Architecture
+ $signals += [pscustomobject]@{ Source = "Win32_Processor.Architecture"; Value = $processorArch }
+ $nativeArchSignalFound = $true
+ }
+ } catch {}
+
+ try {
+ if (-not $nativeArchSignalFound -and (Get-Command Get-WmiObject -ErrorAction SilentlyContinue)) {
+ $processorArch = Get-WmiObject -Class Win32_Processor -ErrorAction Stop |
+ Select-Object -First 1 -ExpandProperty Architecture
+ $signals += [pscustomobject]@{ Source = "Win32_Processor.Architecture"; Value = $processorArch }
+ $nativeArchSignalFound = $true
+ }
+ } catch {}
+
+ try {
+ $signals += [pscustomobject]@{
+ Source = "RuntimeInformation.OSArchitecture"
+ Value = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
+ }
+ } catch {}
+
+ $signals += [pscustomobject]@{ Source = "PROCESSOR_ARCHITEW6432"; Value = $env:PROCESSOR_ARCHITEW6432 }
+ $signals += [pscustomobject]@{ Source = "PROCESSOR_ARCHITECTURE"; Value = $env:PROCESSOR_ARCHITECTURE }
+
+ foreach ($signal in $signals) {
+ $arch = Convert-ToCliArch $signal.Value
+ if ($arch) {
+ return $arch
+ }
+ }
+
+ $details = ($signals |
+ Where-Object { $null -ne $_.Value -and "$($_.Value)".Trim() -ne "" } |
+ ForEach-Object { "$($_.Source)=$($_.Value)" }) -join ", "
+ if (-not $details) {
+ $details = "no architecture signals available"
+ }
+
+ Write-Fail "Unsupported Windows architecture ($details). Only x64 and ARM64 are supported."
+}
+
# ---------------------------------------------------------------------------
# CLI Installation
# ---------------------------------------------------------------------------
@@ -98,35 +170,7 @@ function Install-CliBinary {
Write-Fail "Multica requires a 64-bit Windows installation."
}
- # Distinguish amd64 vs arm64 — Is64BitOperatingSystem is true for both.
- # Use multiple detection methods for robustness
- $osArch = $null
-
- # Method 1: RuntimeInformation (primary)
- try {
- $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
- } catch {}
-
- # Method 2: PROCESSOR_ARCHITECTURE environment variable
- if (-not $osArch) {
- $envArch = $env:PROCESSOR_ARCHITECTURE
- if ($envArch -eq "AMD64") { $osArch = 'X64' }
- elseif ($envArch -eq "ARM64") { $osArch = 'Arm64' }
- }
-
- # Method 3: PROCESSOR_ARCHITEW6432 (for 32-bit PowerShell on 64-bit Windows)
- if (-not $osArch) {
- $envArch = $env:PROCESSOR_ARCHITEW6432
- if ($envArch -eq "AMD64") { $osArch = 'X64' }
- elseif ($envArch -eq "ARM64") { $osArch = 'Arm64' }
- }
-
- # Determine architecture
- switch ($osArch) {
- 'X64' { $arch = "amd64" }
- 'Arm64' { $arch = "arm64" }
- default { Write-Fail "Unsupported Windows architecture: $osArch (only X64 and Arm64 are supported)." }
- }
+ $arch = Get-WindowsCliArch
$latest = Get-LatestVersion
if (-not $latest) {
@@ -389,7 +433,7 @@ function Start-LocalInstall {
Write-Host " multica setup self-host " -NoNewline; Write-Host "# Configure + authenticate + start daemon" -ForegroundColor DarkGray
Write-Host ""
Write-Host " Login: configure RESEND_API_KEY in .env for email codes,"
- Write-Host " or set APP_ENV=development in .env to enable the dev master code 888888."
+ Write-Host " or read the generated code from backend logs when Resend is unset."
Write-Host ""
Write-Host " To stop all services:"
Write-Host ' $env:MULTICA_MODE="stop"; irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex'
diff --git a/scripts/install.sh b/scripts/install.sh
index 4f1c085d45..ebb0d82ca3 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -393,7 +393,7 @@ run_with_server() {
printf " ${CYAN}multica setup self-host${RESET} # Configure + authenticate + start daemon\n"
printf "\n"
printf " ${BOLD}Login:${RESET} configure ${CYAN}RESEND_API_KEY${RESET} in .env for email codes,\n"
- printf " or set ${CYAN}APP_ENV=development${RESET} in .env to enable the dev master code ${BOLD}888888${RESET}.\n"
+ printf " or read the generated code from backend logs when Resend is unset.\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"
diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go
index c8833bf0b4..604bb200af 100644
--- a/server/cmd/multica/cmd_daemon.go
+++ b/server/cmd/multica/cmd_daemon.go
@@ -65,6 +65,7 @@ func init() {
f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
+ f.Duration("codex-semantic-inactivity-timeout", 0, "Codex semantic inactivity timeout (env: MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT)")
f.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)")
daemonLogsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
@@ -81,6 +82,7 @@ func init() {
rf.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
rf.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
rf.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
+ rf.Duration("codex-semantic-inactivity-timeout", 0, "Codex semantic inactivity timeout (env: MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT)")
rf.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)")
daemonCmd.AddCommand(daemonStartCmd)
@@ -174,11 +176,29 @@ func runDaemonBackground(cmd *cobra.Command) error {
child := exec.Command(exePath, args...)
child.Stdout = logFile
child.Stderr = logFile
- child.SysProcAttr = daemonSysProcAttr()
+ // On Windows we want to break the child out of the parent shell's Job
+ // Object so the daemon survives parent-shell exit. If the parent's Job
+ // has not granted BREAKAWAY_OK, CreateProcess returns
+ // ERROR_ACCESS_DENIED — fall back to spawning without breakaway, which
+ // matches the pre-fix behaviour. On Unix the bool is a no-op.
+ child.SysProcAttr = daemonSysProcAttr(true)
if err := child.Start(); err != nil {
- logFile.Close()
- return fmt.Errorf("start daemon: %w", err)
+ if isAccessDeniedSpawnErr(err) {
+ // Retry without breakaway. Reset the cmd state — exec.Cmd is
+ // not safe to Start() twice, so build a fresh one.
+ child = exec.Command(exePath, args...)
+ child.Stdout = logFile
+ child.Stderr = logFile
+ child.SysProcAttr = daemonSysProcAttr(false)
+ if err := child.Start(); err != nil {
+ logFile.Close()
+ return fmt.Errorf("start daemon (no breakaway): %w", err)
+ }
+ } else {
+ logFile.Close()
+ return fmt.Errorf("start daemon: %w", err)
+ }
}
logFile.Close()
pid := child.Process.Pid
@@ -241,6 +261,9 @@ func buildDaemonStartArgs(cmd *cobra.Command) []string {
if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 {
args = append(args, "--agent-timeout", d.String())
}
+ if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 {
+ args = append(args, "--codex-semantic-inactivity-timeout", d.String())
+ }
if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 {
args = append(args, "--max-concurrent-tasks", strconv.Itoa(n))
}
@@ -282,6 +305,9 @@ func runDaemonForeground(cmd *cobra.Command) error {
if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 {
overrides.AgentTimeout = d
}
+ if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 {
+ overrides.CodexSemanticInactivityTimeout = d
+ }
if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 {
overrides.MaxConcurrentTasks = n
}
@@ -327,12 +353,26 @@ func runDaemonForeground(cmd *cobra.Command) error {
}
child.Stdout = logFile
child.Stderr = logFile
- child.SysProcAttr = daemonSysProcAttr()
+ // Break out of the parent's Job Object on Windows; see the
+ // runDaemonBackground call site for rationale.
+ child.SysProcAttr = daemonSysProcAttr(true)
if err := child.Start(); err != nil {
- logFile.Close()
- logger.Error("failed to start new daemon", "error", err)
- return nil
+ if isAccessDeniedSpawnErr(err) {
+ child = exec.Command(restartBin, args...)
+ child.Stdout = logFile
+ child.Stderr = logFile
+ child.SysProcAttr = daemonSysProcAttr(false)
+ if err := child.Start(); err != nil {
+ logFile.Close()
+ logger.Error("failed to start new daemon (no breakaway)", "error", err)
+ return nil
+ }
+ } else {
+ logFile.Close()
+ logger.Error("failed to start new daemon", "error", err)
+ return nil
+ }
}
logFile.Close()
child.Process.Release()
diff --git a/server/cmd/multica/cmd_daemon_unix.go b/server/cmd/multica/cmd_daemon_unix.go
index 9a84648b79..5c2a99c231 100644
--- a/server/cmd/multica/cmd_daemon_unix.go
+++ b/server/cmd/multica/cmd_daemon_unix.go
@@ -11,10 +11,21 @@ import (
"syscall"
)
-func daemonSysProcAttr() *syscall.SysProcAttr {
+// daemonSysProcAttr returns the attributes used when spawning the background
+// daemon. The withBreakaway argument exists only to share a signature with
+// the Windows version (where it controls CREATE_BREAKAWAY_FROM_JOB); on
+// Unix Setsid alone is sufficient to detach the child from its parent's
+// session and process group.
+func daemonSysProcAttr(_ bool) *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}
+// isAccessDeniedSpawnErr is always false on Unix. The Windows version
+// looks for ERROR_ACCESS_DENIED to detect "parent Job Object disallowed
+// breakaway" and trigger the breakaway-disabled retry; that retry is a
+// no-op on Unix.
+func isAccessDeniedSpawnErr(_ error) bool { return false }
+
func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
}
diff --git a/server/cmd/multica/cmd_daemon_windows.go b/server/cmd/multica/cmd_daemon_windows.go
index 6836bfb6eb..c90bbc865c 100644
--- a/server/cmd/multica/cmd_daemon_windows.go
+++ b/server/cmd/multica/cmd_daemon_windows.go
@@ -4,6 +4,7 @@ package main
import (
"context"
+ "errors"
"io"
"os"
"os/signal"
@@ -12,25 +13,57 @@ import (
)
const (
+ // detachedProcess severs the inherited console so closing the parent
+ // cmd/PowerShell window no longer propagates CTRL_CLOSE_EVENT to the daemon.
detachedProcess = 0x00000008
- sigBreak = syscall.Signal(0x15)
+ // createBreakawayFromJob lets the daemon escape its parent shell's Job
+ // Object. Modern Windows Terminal / cmd.exe / PowerShell host the
+ // processes they spawn inside a Job Object that has KILL_ON_JOB_CLOSE
+ // set, so when the parent shell exits the kernel kills every process
+ // inside that job — including a child we tried to "detach" with
+ // detachedProcess alone. detachedProcess only severs the console, not
+ // the Job Object inheritance. Adding createBreakawayFromJob makes
+ // CreateProcess place the new process outside the parent's Job, so
+ // the daemon survives parent-shell exit.
+ //
+ // If the parent's Job has not granted BREAKAWAY_OK, CreateProcess
+ // returns ERROR_ACCESS_DENIED. In that case the caller falls back to
+ // detachedProcess alone — the daemon is then at the mercy of the
+ // parent's Job lifecycle, which is the pre-fix behaviour.
+ createBreakawayFromJob = 0x01000000
+ sigBreak = syscall.Signal(0x15)
)
// daemonSysProcAttr returns the attributes used when spawning the background
-// daemon. DETACHED_PROCESS severs the inherited console so closing the parent
-// cmd/PowerShell window no longer propagates CTRL_CLOSE_EVENT to the daemon.
-// Because the detached daemon shares no console with the stop caller,
-// `daemon stop` talks to it via the HTTP /shutdown endpoint rather than
-// GenerateConsoleCtrlEvent. The daemon's stdout/stderr are already
-// redirected to the log file before Start() is called, so losing the
-// console is safe.
-func daemonSysProcAttr() *syscall.SysProcAttr {
+// daemon. The default is detachedProcess + createBreakawayFromJob so the
+// daemon survives both the parent's console close and the parent's Job
+// Object close. The daemon's stdout/stderr are already redirected to the
+// log file before Start() is called, so losing the console is safe; and
+// `daemon stop` talks to it via HTTP /shutdown rather than
+// GenerateConsoleCtrlEvent, so losing the process group is also safe.
+//
+// The withBreakaway argument exists so the caller can retry with
+// withBreakaway=false when CreateProcess fails with ERROR_ACCESS_DENIED
+// (the parent Job does not allow breakaway).
+func daemonSysProcAttr(withBreakaway bool) *syscall.SysProcAttr {
+ flags := uint32(detachedProcess)
+ if withBreakaway {
+ flags |= createBreakawayFromJob
+ }
return &syscall.SysProcAttr{
HideWindow: true,
- CreationFlags: detachedProcess,
+ CreationFlags: flags,
}
}
+// isAccessDeniedSpawnErr reports whether the error returned from
+// (*exec.Cmd).Start() is the Windows ERROR_ACCESS_DENIED, which is what
+// CreateProcess returns when CREATE_BREAKAWAY_FROM_JOB is requested but
+// the parent's Job Object has not set JOB_OBJECT_LIMIT_BREAKAWAY_OK.
+func isAccessDeniedSpawnErr(err error) bool {
+ return errors.Is(err, syscall.ERROR_ACCESS_DENIED)
+}
+
func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, os.Interrupt, sigBreak)
}
diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go
index 6c3d3911bf..4905a3e3d9 100644
--- a/server/cmd/multica/cmd_issue.go
+++ b/server/cmd/multica/cmd_issue.go
@@ -15,6 +15,79 @@ import (
"github.com/multica-ai/multica/server/internal/cli"
)
+// resolveTextFlag picks between a `--
` flag value and a paired
+// `---stdin` flag, mirroring the existing `--content` / `--content-stdin`
+// pattern. It returns the resolved string and an error when both are set or
+// stdin is requested but produces no body. The resulting text is returned
+// verbatim — callers decide whether to apply unescapeFlagText to the inline
+// flag form (and never to the stdin form, which already preserves literal
+// backslashes).
+func resolveTextFlag(cmd *cobra.Command, flagName string) (string, bool, error) {
+ stdinFlag := flagName + "-stdin"
+ useStdin, _ := cmd.Flags().GetBool(stdinFlag)
+ inline, _ := cmd.Flags().GetString(flagName)
+ if useStdin && inline != "" {
+ return "", false, fmt.Errorf("--%s and --%s are mutually exclusive", flagName, stdinFlag)
+ }
+ if useStdin {
+ data, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ return "", false, fmt.Errorf("read stdin for --%s: %w", stdinFlag, err)
+ }
+ body := strings.TrimSuffix(string(data), "\n")
+ if body == "" {
+ return "", false, fmt.Errorf("stdin content for --%s is empty", stdinFlag)
+ }
+ return body, true, nil
+ }
+ if inline == "" {
+ return "", false, nil
+ }
+ return unescapeFlagText(inline), true, nil
+}
+
+// unescapeFlagText decodes the common backslash escape sequences (\n, \r, \t,
+// \\) in a free-form string flag value. Shells like bash do not expand these
+// inside double quotes, so an LLM agent that emits
+// `--content "para1\n\npara2"` ends up sending the literal 4-char sequence to
+// the CLI and then to storage, where it renders as text rather than as line
+// breaks. Decoding here makes the flag behave the way callers intuit; users
+// who genuinely need a literal backslash-n can write `\\n` or pipe the body
+// via `--content-stdin` / `--description-stdin`, which bypass this path
+// entirely.
+func unescapeFlagText(s string) string {
+ if !strings.ContainsRune(s, '\\') {
+ return s
+ }
+ var b strings.Builder
+ b.Grow(len(s))
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c == '\\' && i+1 < len(s) {
+ switch s[i+1] {
+ case 'n':
+ b.WriteByte('\n')
+ i++
+ continue
+ case 'r':
+ b.WriteByte('\r')
+ i++
+ continue
+ case 't':
+ b.WriteByte('\t')
+ i++
+ continue
+ case '\\':
+ b.WriteByte('\\')
+ i++
+ continue
+ }
+ }
+ b.WriteByte(c)
+ }
+ return b.String()
+}
+
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Work with issues",
@@ -186,7 +259,8 @@ func init() {
// issue create
issueCreateCmd.Flags().String("title", "", "Issue title (required)")
- issueCreateCmd.Flags().String("description", "", "Issue description")
+ issueCreateCmd.Flags().String("description", "", "Issue description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)")
+ issueCreateCmd.Flags().Bool("description-stdin", false, "Read issue description from stdin (preserves multi-line content verbatim)")
issueCreateCmd.Flags().String("status", "", "Issue status")
issueCreateCmd.Flags().String("priority", "", "Issue priority")
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent)")
@@ -198,7 +272,8 @@ func init() {
// issue update
issueUpdateCmd.Flags().String("title", "", "New title")
- issueUpdateCmd.Flags().String("description", "", "New description")
+ issueUpdateCmd.Flags().String("description", "", "New description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)")
+ issueUpdateCmd.Flags().Bool("description-stdin", false, "Read new description from stdin (preserves multi-line content verbatim)")
issueUpdateCmd.Flags().String("status", "", "New status")
issueUpdateCmd.Flags().String("priority", "", "New priority")
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)")
@@ -232,8 +307,8 @@ func init() {
issueRunMessagesCmd.Flags().Int("since", 0, "Only return messages after this sequence number")
// issue comment add
- issueCommentAddCmd.Flags().String("content", "", "Comment content (required unless --content-stdin)")
- issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (avoids shell escaping issues)")
+ issueCommentAddCmd.Flags().String("content", "", "Comment content (decodes \\n, \\r, \\t, \\\\; pipe via --content-stdin for multi-line bodies or to preserve literal backslashes)")
+ issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (preserves multi-line content verbatim)")
issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)")
issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json")
@@ -411,8 +486,12 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
defer cancel()
body := map[string]any{"title": title}
- if v, _ := cmd.Flags().GetString("description"); v != "" {
- body["description"] = v
+ desc, hasDesc, err := resolveTextFlag(cmd, "description")
+ if err != nil {
+ return err
+ }
+ if hasDesc {
+ body["description"] = desc
}
if v, _ := cmd.Flags().GetString("status"); v != "" {
body["status"] = v
@@ -486,9 +565,12 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
v, _ := cmd.Flags().GetString("title")
body["title"] = v
}
- if cmd.Flags().Changed("description") {
- v, _ := cmd.Flags().GetString("description")
- body["description"] = v
+ if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-stdin") {
+ desc, _, err := resolveTextFlag(cmd, "description")
+ if err != nil {
+ return err
+ }
+ body["description"] = desc
}
if cmd.Flags().Changed("status") {
v, _ := cmd.Flags().GetString("status")
@@ -717,25 +799,11 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
}
func runIssueCommentAdd(cmd *cobra.Command, args []string) error {
- content, _ := cmd.Flags().GetString("content")
- useStdin, _ := cmd.Flags().GetBool("content-stdin")
-
- if content != "" && useStdin {
- return fmt.Errorf("--content and --content-stdin are mutually exclusive")
+ content, hasContent, err := resolveTextFlag(cmd, "content")
+ if err != nil {
+ return err
}
-
- if useStdin {
- data, err := io.ReadAll(os.Stdin)
- if err != nil {
- return fmt.Errorf("read stdin: %w", err)
- }
- content = strings.TrimSuffix(string(data), "\n")
- if content == "" {
- return fmt.Errorf("stdin content is empty")
- }
- }
-
- if content == "" {
+ if !hasContent {
return fmt.Errorf("--content or --content-stdin is required")
}
diff --git a/server/cmd/multica/cmd_issue_label.go b/server/cmd/multica/cmd_issue_label.go
new file mode 100644
index 0000000000..2700dcf537
--- /dev/null
+++ b/server/cmd/multica/cmd_issue_label.go
@@ -0,0 +1,148 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/multica-ai/multica/server/internal/cli"
+)
+
+// multica issue label {list|add|remove} — manages the labels attached to a
+// specific issue. The label itself is managed via `multica label ...`.
+
+var issueLabelCmd = &cobra.Command{
+ Use: "label",
+ Short: "Manage labels on an issue",
+}
+
+var issueLabelListCmd = &cobra.Command{
+ Use: "list ",
+ Short: "List labels on an issue",
+ Args: exactArgs(1),
+ RunE: runIssueLabelList,
+}
+
+var issueLabelAddCmd = &cobra.Command{
+ Use: "add ",
+ Short: "Attach a label to an issue",
+ Args: exactArgs(2),
+ RunE: runIssueLabelAdd,
+}
+
+var issueLabelRemoveCmd = &cobra.Command{
+ Use: "remove ",
+ Short: "Remove a label from an issue",
+ Args: exactArgs(2),
+ RunE: runIssueLabelRemove,
+}
+
+func init() {
+ issueLabelCmd.AddCommand(issueLabelListCmd)
+ issueLabelCmd.AddCommand(issueLabelAddCmd)
+ issueLabelCmd.AddCommand(issueLabelRemoveCmd)
+
+ issueLabelListCmd.Flags().String("output", "table", "Output format: table or json")
+ issueLabelAddCmd.Flags().String("output", "table", "Output format: table or json")
+ issueLabelRemoveCmd.Flags().String("output", "table", "Output format: table or json")
+
+ // Register under the top-level `issue` command.
+ issueCmd.AddCommand(issueLabelCmd)
+}
+
+func runIssueLabelList(cmd *cobra.Command, args []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ var result map[string]any
+ if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/labels", &result); err != nil {
+ return fmt.Errorf("list issue labels: %w", err)
+ }
+ labelsRaw, _ := result["labels"].([]any)
+
+ output, _ := cmd.Flags().GetString("output")
+ if output == "json" {
+ return cli.PrintJSON(os.Stdout, labelsRaw)
+ }
+ printLabelTable(labelsRaw)
+ return nil
+}
+
+func runIssueLabelAdd(cmd *cobra.Command, args []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ body := map[string]any{"label_id": args[1]}
+ var result map[string]any
+ if err := client.PostJSON(ctx, "/api/issues/"+args[0]+"/labels", body, &result); err != nil {
+ return fmt.Errorf("attach label: %w", err)
+ }
+ labelsRaw, _ := result["labels"].([]any)
+
+ output, _ := cmd.Flags().GetString("output")
+ if output == "json" {
+ return cli.PrintJSON(os.Stdout, labelsRaw)
+ }
+ printLabelTable(labelsRaw)
+ return nil
+}
+
+func runIssueLabelRemove(cmd *cobra.Command, args []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ if err := client.DeleteJSON(ctx, "/api/issues/"+args[0]+"/labels/"+args[1]); err != nil {
+ return fmt.Errorf("detach label: %w", err)
+ }
+
+ // Follow up with the current label list so the user sees the result.
+ // If the refresh fails, still print a clear success message — the
+ // detach itself already succeeded.
+ var result map[string]any
+ output, _ := cmd.Flags().GetString("output")
+ if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/labels", &result); err != nil {
+ if output == "json" {
+ return cli.PrintJSON(os.Stdout, map[string]any{"detached": true})
+ }
+ fmt.Fprintln(os.Stdout, "Label detached.")
+ return nil
+ }
+ labelsRaw, _ := result["labels"].([]any)
+ if output == "json" {
+ return cli.PrintJSON(os.Stdout, labelsRaw)
+ }
+ printLabelTable(labelsRaw)
+ return nil
+}
+
+func printLabelTable(labels []any) {
+ headers := []string{"ID", "NAME", "COLOR"}
+ rows := make([][]string, 0, len(labels))
+ for _, raw := range labels {
+ l, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ rows = append(rows, []string{
+ truncateID(strVal(l, "id")),
+ strVal(l, "name"),
+ strVal(l, "color"),
+ })
+ }
+ cli.PrintTable(os.Stdout, headers, rows)
+}
diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go
index e0535d08c1..d3aa12d143 100644
--- a/server/cmd/multica/cmd_issue_test.go
+++ b/server/cmd/multica/cmd_issue_test.go
@@ -5,12 +5,145 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
"strings"
"testing"
+ "github.com/spf13/cobra"
+
"github.com/multica-ai/multica/server/internal/cli"
)
+// pipeStdin replaces os.Stdin with a pipe seeded by the given body for the
+// duration of fn, so resolveTextFlag's --content-stdin / --description-stdin
+// branch can be exercised in unit tests without spawning a subprocess.
+func pipeStdin(t *testing.T, body string, fn func()) {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ if _, err := w.WriteString(body); err != nil {
+ t.Fatalf("write pipe: %v", err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("close pipe writer: %v", err)
+ }
+ orig := os.Stdin
+ os.Stdin = r
+ defer func() {
+ os.Stdin = orig
+ _ = r.Close()
+ }()
+ fn()
+}
+
+// newFlagTestCmd builds a throwaway cobra.Command carrying the inline +
+// stdin flag pair that resolveTextFlag expects.
+func newFlagTestCmd(name string) *cobra.Command {
+ c := &cobra.Command{Use: "test"}
+ c.Flags().String(name, "", "")
+ c.Flags().Bool(name+"-stdin", false, "")
+ return c
+}
+
+func TestResolveTextFlag(t *testing.T) {
+ t.Run("inline value is unescaped", func(t *testing.T) {
+ c := newFlagTestCmd("description")
+ _ = c.Flags().Set("description", `para1\n\npara2`)
+ got, ok, err := resolveTextFlag(c, "description")
+ if err != nil || !ok {
+ t.Fatalf("unexpected: ok=%v err=%v", ok, err)
+ }
+ if got != "para1\n\npara2" {
+ t.Errorf("got %q, want decoded paragraphs", got)
+ }
+ })
+
+ t.Run("stdin body is preserved verbatim", func(t *testing.T) {
+ c := newFlagTestCmd("description")
+ _ = c.Flags().Set("description-stdin", "true")
+ body := "first line\nsecond line with a literal \\n in it\n"
+ pipeStdin(t, body, func() {
+ got, ok, err := resolveTextFlag(c, "description")
+ if err != nil || !ok {
+ t.Fatalf("unexpected: ok=%v err=%v", ok, err)
+ }
+ // strings.TrimSuffix one trailing newline like content-stdin.
+ want := "first line\nsecond line with a literal \\n in it"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+ })
+
+ t.Run("inline + stdin is rejected", func(t *testing.T) {
+ c := newFlagTestCmd("description")
+ _ = c.Flags().Set("description", "inline")
+ _ = c.Flags().Set("description-stdin", "true")
+ if _, _, err := resolveTextFlag(c, "description"); err == nil {
+ t.Fatalf("expected mutually-exclusive error")
+ }
+ })
+
+ t.Run("missing both returns hasValue=false", func(t *testing.T) {
+ c := newFlagTestCmd("description")
+ got, ok, err := resolveTextFlag(c, "description")
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if ok || got != "" {
+ t.Errorf("expected absent flag to yield (\"\", false), got (%q, %v)", got, ok)
+ }
+ })
+}
+
+func TestUnescapeFlagText(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty", "", ""},
+ {"no escapes", "hello world", "hello world"},
+ {"single newline", `line1\nline2`, "line1\nline2"},
+ {"double newline becomes paragraph", `para1\n\npara2`, "para1\n\npara2"},
+ {"tab and carriage return", `a\tb\rc`, "a\tb\rc"},
+ {"escaped backslash preserved as literal", `keep\\nliteral`, `keep\nliteral`},
+ {"trailing lone backslash kept verbatim", `tail\`, `tail\`},
+ {"unknown escape kept verbatim", `\x not touched`, `\x not touched`},
+ {"mixed real and escaped newlines", "real\n" + `and\nescaped`, "real\nand\nescaped"},
+ {"unicode untouched", `中文段落\n下一段`, "中文段落\n下一段"},
+ // Contract boundary: only \n \r \t \\ are decoded. Common regex /
+ // path / formatter escape sequences such as \d, \w, \s, \u, \0 must
+ // pass through verbatim — this lets users paste regex snippets or
+ // printf-style format strings into --content without surprise
+ // mutation. Anyone who genuinely wants the literal characters \\n
+ // can either double the backslash or pipe the body via stdin.
+ {"regex digit class untouched", `\d+\s*\w+`, `\d+\s*\w+`},
+ {"unicode escape untouched", `café`, `café`},
+ {"null escape untouched", `\0 sentinel`, `\0 sentinel`},
+ {"windows path no special chars", `C:\Users\bob`, `C:\Users\bob`},
+ {"backslash-quote pair untouched", `quote\"inside`, `quote\"inside`},
+ // Documented sharp edge of the contract: a path or string that
+ // embeds a literal backslash-n IS rewritten because the helper
+ // cannot distinguish "model emitted \n thinking it would become a
+ // newline" from "user pasted a path that happens to start with
+ // \new". Callers who need the literal sequence must double the
+ // backslash (`\\new`) or pipe the body via --content-stdin /
+ // --description-stdin. This test pins that intentional behavior.
+ {"path starting with backslash-n is mutated", `C:\new\folder`, "C:\new\\folder"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := unescapeFlagText(tt.in)
+ if got != tt.want {
+ t.Errorf("unescapeFlagText(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
func TestTruncateID(t *testing.T) {
tests := []struct {
name string
diff --git a/server/cmd/multica/cmd_label.go b/server/cmd/multica/cmd_label.go
new file mode 100644
index 0000000000..3fa46d31be
--- /dev/null
+++ b/server/cmd/multica/cmd_label.go
@@ -0,0 +1,252 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/multica-ai/multica/server/internal/cli"
+)
+
+// ---------------------------------------------------------------------------
+// Label commands — workspace-scoped CRUD for issue labels.
+// ---------------------------------------------------------------------------
+
+var labelCmd = &cobra.Command{
+ Use: "label",
+ Short: "Work with issue labels",
+}
+
+var labelListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List labels in the workspace",
+ RunE: runLabelList,
+}
+
+var labelGetCmd = &cobra.Command{
+ Use: "get ",
+ Short: "Get label details",
+ Args: exactArgs(1),
+ RunE: runLabelGet,
+}
+
+var labelCreateCmd = &cobra.Command{
+ Use: "create",
+ Short: "Create a new label",
+ RunE: runLabelCreate,
+}
+
+var labelUpdateCmd = &cobra.Command{
+ Use: "update ",
+ Short: "Update a label",
+ Args: exactArgs(1),
+ RunE: runLabelUpdate,
+}
+
+var labelDeleteCmd = &cobra.Command{
+ Use: "delete ",
+ Short: "Delete a label",
+ Args: exactArgs(1),
+ RunE: runLabelDelete,
+}
+
+func init() {
+ labelCmd.AddCommand(labelListCmd)
+ labelCmd.AddCommand(labelGetCmd)
+ labelCmd.AddCommand(labelCreateCmd)
+ labelCmd.AddCommand(labelUpdateCmd)
+ labelCmd.AddCommand(labelDeleteCmd)
+
+ labelListCmd.Flags().String("output", "table", "Output format: table or json")
+ labelGetCmd.Flags().String("output", "json", "Output format: table or json")
+
+ labelCreateCmd.Flags().String("name", "", "Label name (required)")
+ labelCreateCmd.Flags().String("color", "", "Hex color like #3b82f6 (required)")
+ labelCreateCmd.Flags().String("output", "json", "Output format: table or json")
+
+ labelUpdateCmd.Flags().String("name", "", "New name")
+ labelUpdateCmd.Flags().String("color", "", "New hex color")
+ labelUpdateCmd.Flags().String("output", "json", "Output format: table or json")
+
+ labelDeleteCmd.Flags().String("output", "json", "Output format: table or json")
+}
+
+func runLabelList(cmd *cobra.Command, _ []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ params := url.Values{}
+ if client.WorkspaceID != "" {
+ params.Set("workspace_id", client.WorkspaceID)
+ }
+ path := "/api/labels"
+ if len(params) > 0 {
+ path += "?" + params.Encode()
+ }
+
+ var result map[string]any
+ if err := client.GetJSON(ctx, path, &result); err != nil {
+ return fmt.Errorf("list labels: %w", err)
+ }
+ labelsRaw, _ := result["labels"].([]any)
+
+ output, _ := cmd.Flags().GetString("output")
+ if output == "json" {
+ return cli.PrintJSON(os.Stdout, labelsRaw)
+ }
+
+ headers := []string{"ID", "NAME", "COLOR", "CREATED"}
+ rows := make([][]string, 0, len(labelsRaw))
+ for _, raw := range labelsRaw {
+ l, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ created := strVal(l, "created_at")
+ if len(created) >= 10 {
+ created = created[:10]
+ }
+ rows = append(rows, []string{
+ truncateID(strVal(l, "id")),
+ strVal(l, "name"),
+ strVal(l, "color"),
+ created,
+ })
+ }
+ cli.PrintTable(os.Stdout, headers, rows)
+ return nil
+}
+
+func runLabelGet(cmd *cobra.Command, args []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ var label map[string]any
+ if err := client.GetJSON(ctx, "/api/labels/"+args[0], &label); err != nil {
+ return fmt.Errorf("get label: %w", err)
+ }
+
+ output, _ := cmd.Flags().GetString("output")
+ if output == "table" {
+ headers := []string{"ID", "NAME", "COLOR", "CREATED"}
+ created := strVal(label, "created_at")
+ if len(created) >= 10 {
+ created = created[:10]
+ }
+ rows := [][]string{{
+ truncateID(strVal(label, "id")),
+ strVal(label, "name"),
+ strVal(label, "color"),
+ created,
+ }}
+ cli.PrintTable(os.Stdout, headers, rows)
+ return nil
+ }
+ return cli.PrintJSON(os.Stdout, label)
+}
+
+func runLabelCreate(cmd *cobra.Command, _ []string) error {
+ name, _ := cmd.Flags().GetString("name")
+ color, _ := cmd.Flags().GetString("color")
+ if name == "" {
+ return fmt.Errorf("--name is required")
+ }
+ if color == "" {
+ return fmt.Errorf("--color is required (e.g. #3b82f6)")
+ }
+
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ body := map[string]any{"name": name, "color": color}
+ var result map[string]any
+ if err := client.PostJSON(ctx, "/api/labels", body, &result); err != nil {
+ return fmt.Errorf("create label: %w", err)
+ }
+
+ output, _ := cmd.Flags().GetString("output")
+ if output == "table" {
+ headers := []string{"ID", "NAME", "COLOR"}
+ rows := [][]string{{
+ truncateID(strVal(result, "id")),
+ strVal(result, "name"),
+ strVal(result, "color"),
+ }}
+ cli.PrintTable(os.Stdout, headers, rows)
+ return nil
+ }
+ return cli.PrintJSON(os.Stdout, result)
+}
+
+func runLabelUpdate(cmd *cobra.Command, args []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ body := map[string]any{}
+ if v, _ := cmd.Flags().GetString("name"); v != "" {
+ body["name"] = v
+ }
+ if v, _ := cmd.Flags().GetString("color"); v != "" {
+ body["color"] = v
+ }
+ if len(body) == 0 {
+ return fmt.Errorf("nothing to update — provide --name and/or --color")
+ }
+
+ var result map[string]any
+ if err := client.PutJSON(ctx, "/api/labels/"+args[0], body, &result); err != nil {
+ return fmt.Errorf("update label: %w", err)
+ }
+
+ output, _ := cmd.Flags().GetString("output")
+ if output == "table" {
+ headers := []string{"ID", "NAME", "COLOR"}
+ rows := [][]string{{
+ truncateID(strVal(result, "id")),
+ strVal(result, "name"),
+ strVal(result, "color"),
+ }}
+ cli.PrintTable(os.Stdout, headers, rows)
+ return nil
+ }
+ return cli.PrintJSON(os.Stdout, result)
+}
+
+func runLabelDelete(cmd *cobra.Command, args []string) error {
+ client, err := newAPIClient(cmd)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ if err := client.DeleteJSON(ctx, "/api/labels/"+args[0]); err != nil {
+ return fmt.Errorf("delete label: %w", err)
+ }
+ // JSON consumers get machine-readable output; humans get natural language.
+ if output, _ := cmd.Flags().GetString("output"); output == "json" {
+ return cli.PrintJSON(os.Stdout, map[string]any{"id": args[0], "deleted": true})
+ }
+ fmt.Fprintf(os.Stdout, "Label %s deleted.\n", args[0])
+ return nil
+}
diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go
index afe9d455af..2a89aee320 100644
--- a/server/cmd/multica/main.go
+++ b/server/cmd/multica/main.go
@@ -39,6 +39,7 @@ func init() {
// Core commands
issueCmd.GroupID = groupCore
projectCmd.GroupID = groupCore
+ labelCmd.GroupID = groupCore
agentCmd.GroupID = groupCore
autopilotCmd.GroupID = groupCore
workspaceCmd.GroupID = groupCore
@@ -60,6 +61,7 @@ func init() {
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(projectCmd)
+ rootCmd.AddCommand(labelCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(autopilotCmd)
rootCmd.AddCommand(workspaceCmd)
diff --git a/server/cmd/server/activity_listeners_test.go b/server/cmd/server/activity_listeners_test.go
index aea726de23..197b90b51a 100644
--- a/server/cmd/server/activity_listeners_test.go
+++ b/server/cmd/server/activity_listeners_test.go
@@ -16,7 +16,7 @@ import (
func listActivitiesForIssue(t *testing.T, queries *db.Queries, issueID string) []db.ActivityLog {
t.Helper()
activities, err := queries.ListActivities(context.Background(), db.ListActivitiesParams{
- IssueID: util.ParseUUID(issueID),
+ IssueID: util.MustParseUUID(issueID),
Limit: 100,
Offset: 0,
})
diff --git a/server/cmd/server/comment_trigger_integration_test.go b/server/cmd/server/comment_trigger_integration_test.go
index 74bb134311..f5b20a0d42 100644
--- a/server/cmd/server/comment_trigger_integration_test.go
+++ b/server/cmd/server/comment_trigger_integration_test.go
@@ -512,6 +512,41 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) {
})
}
+// TestDeleteCommentCancelsTriggeredTasks verifies that deleting a comment
+// also cancels any active tasks that were triggered by it. Without this,
+// the daemon would still claim the queued task after the FK SET NULL
+// nullified its trigger_comment_id, and the agent would either run with a
+// stale prompt (race during claim) or with a generic "you are assigned"
+// prompt that has no record of the now-deleted user request — both of
+// which manifest as "the agent still sees the deleted comment".
+func TestDeleteCommentCancelsTriggeredTasks(t *testing.T) {
+ agentID := getAgentID(t)
+ issueID := createIssueAssignedToAgent(t, "Delete-comment cancels task test", agentID)
+ t.Cleanup(func() {
+ clearTasks(t, issueID)
+ resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
+ resp.Body.Close()
+ })
+
+ t.Run("deleting trigger comment cancels its queued task", func(t *testing.T) {
+ clearTasks(t, issueID)
+ commentID := postComment(t, issueID, "Please fix this bug", nil)
+ if n := countPendingTasks(t, issueID); n != 1 {
+ t.Fatalf("expected 1 pending task before delete, got %d", n)
+ }
+
+ resp := authRequest(t, "DELETE", "/api/comments/"+commentID, nil)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("DeleteComment: expected 204, got %d", resp.StatusCode)
+ }
+
+ if n := countPendingTasks(t, issueID); n != 0 {
+ t.Errorf("expected 0 pending tasks after deleting trigger comment, got %d", n)
+ }
+ })
+}
+
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create
// duplicate tasks (coalescing dedup).
func TestCommentTriggerCoalescing(t *testing.T) {
diff --git a/server/cmd/server/health_realtime.go b/server/cmd/server/health_realtime.go
index 2a8748ae7f..00ae3a0841 100644
--- a/server/cmd/server/health_realtime.go
+++ b/server/cmd/server/health_realtime.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
+ "github.com/multica-ai/multica/server/internal/daemonws"
"github.com/multica-ai/multica/server/internal/realtime"
)
@@ -47,7 +48,9 @@ func realtimeMetricsHandler(token string) http.HandlerFunc {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
- _ = json.NewEncoder(w).Encode(realtime.M.Snapshot())
+ snapshot := realtime.M.Snapshot()
+ snapshot["daemonws"] = daemonws.M.Snapshot()
+ _ = json.NewEncoder(w).Encode(snapshot)
}
}
diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go
index 02fcaa20e1..aa990e13a9 100644
--- a/server/cmd/server/main.go
+++ b/server/cmd/server/main.go
@@ -12,14 +12,21 @@ import (
"time"
"github.com/multica-ai/multica/server/internal/analytics"
+ "github.com/multica-ai/multica/server/internal/daemonws"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/logger"
+ obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/redis/go-redis/v9"
)
+var (
+ version = "dev"
+ commit = "unknown"
+)
+
func newNamedRedisClient(base *redis.Options, suffix string) *redis.Client {
opts := *base
opts.ClientName = redisClientName(opts.ClientName, suffix)
@@ -118,6 +125,13 @@ func main() {
if os.Getenv("RESEND_API_KEY") == "" {
slog.Warn("RESEND_API_KEY is not set — email verification codes will be printed to the log instead of emailed.")
}
+ if os.Getenv("MULTICA_DEV_VERIFICATION_CODE") != "" {
+ if strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production") {
+ slog.Warn("MULTICA_DEV_VERIFICATION_CODE is set but ignored because APP_ENV=production.")
+ } else {
+ slog.Warn("MULTICA_DEV_VERIFICATION_CODE is enabled. Use it only for local development or private test instances.")
+ }
+ }
port := os.Getenv("PORT")
if port == "" {
@@ -148,6 +162,8 @@ func main() {
bus := events.New()
hub := realtime.NewHub()
go hub.Run()
+ daemonHub := daemonws.NewHub()
+ var daemonWakeup service.TaskWakeupNotifier = daemonHub
// MUL-1138: when REDIS_URL is set, route fanout through a Redis relay so
// multiple API nodes can deliver each other's events. Without it the hub
@@ -191,15 +207,21 @@ func main() {
case "legacy":
relayReadRedis = newNamedRedisClient(opts, "realtime-read")
relay = realtime.NewRedisRelayWithClients(hub, relayWriteRedis, relayReadRedis)
+ slog.Info("daemon websocket wakeup: Redis fanout disabled in legacy realtime relay mode")
case "dual":
shardedReadRedis = newNamedRedisClient(opts, "realtime-read-sharded")
legacyReadRedis = newNamedRedisClient(opts, "realtime-read-legacy")
sharded := realtime.NewShardedStreamRelay(hub, relayWriteRedis, shardedReadRedis, relayConfig)
+ sharded.SetDaemonRuntimeDeliverer(daemonHub)
legacy := realtime.NewRedisRelayWithClients(hub, relayWriteRedis, legacyReadRedis)
relay = realtime.NewMirroredRelay(sharded, legacy)
+ daemonWakeup = daemonws.NewRelayNotifier(daemonHub, sharded)
default:
relayReadRedis = newNamedRedisClient(opts, "realtime-read")
- relay = realtime.NewShardedStreamRelay(hub, relayWriteRedis, relayReadRedis, relayConfig)
+ sharded := realtime.NewShardedStreamRelay(hub, relayWriteRedis, relayReadRedis, relayConfig)
+ sharded.SetDaemonRuntimeDeliverer(daemonHub)
+ relay = sharded
+ daemonWakeup = daemonws.NewRelayNotifier(daemonHub, sharded)
}
relay.Start(relayCtx)
broadcaster = realtime.NewDualWriteBroadcaster(hub, relay)
@@ -233,7 +255,32 @@ func main() {
registerActivityListeners(bus, queries)
registerNotificationListeners(bus, queries)
- r := NewRouter(pool, hub, bus, analyticsClient, storeRedis)
+ metricsConfig := obsmetrics.ConfigFromEnv()
+ var metricsServer *http.Server
+ var httpMetrics *obsmetrics.HTTPMetrics
+ if metricsConfig.Enabled() {
+ metricsRegistry := obsmetrics.NewRegistry(obsmetrics.RegistryOptions{
+ Pool: pool,
+ Realtime: realtime.M,
+ DaemonWS: daemonws.M,
+ Version: version,
+ Commit: commit,
+ })
+ httpMetrics = metricsRegistry.HTTP
+ metricsServer = obsmetrics.NewServer(metricsConfig.Addr, metricsRegistry.Gatherer)
+ if !obsmetrics.IsLoopbackAddr(metricsConfig.Addr) {
+ slog.Warn(
+ "metrics listener is not loopback-only; restrict access with private networking, allowlists, or proxy auth",
+ "addr", metricsConfig.Addr,
+ )
+ }
+ }
+
+ r := NewRouterWithOptions(pool, hub, bus, analyticsClient, storeRedis, RouterOptions{
+ HTTPMetrics: httpMetrics,
+ DaemonHub: daemonHub,
+ DaemonWakeup: daemonWakeup,
+ })
srv := &http.Server{
Addr: ":" + port,
@@ -243,7 +290,7 @@ func main() {
// Start background workers.
sweepCtx, sweepCancel := context.WithCancel(context.Background())
autopilotCtx, autopilotCancel := context.WithCancel(context.Background())
- taskSvc := service.NewTaskService(queries, pool, hub, bus)
+ taskSvc := service.NewTaskService(queries, pool, hub, bus, daemonWakeup)
autopilotSvc := service.NewAutopilotService(queries, pool, bus, taskSvc)
registerAutopilotListeners(bus, autopilotSvc)
@@ -252,7 +299,15 @@ func main() {
go runAutopilotScheduler(autopilotCtx, queries, autopilotSvc)
go runDBStatsLogger(sweepCtx, pool)
- // Graceful shutdown
+ if metricsServer != nil {
+ go func() {
+ slog.Info("metrics server starting", "addr", metricsConfig.Addr)
+ if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ slog.Error("metrics server disabled after startup error", "error", err)
+ }
+ }()
+ }
+
go func() {
slog.Info("server starting", "port", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -268,12 +323,21 @@ func main() {
slog.Info("shutting down server")
sweepCancel()
autopilotCancel()
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := srv.Shutdown(shutdownCtx); err != nil {
+ apiShutdownCtx, apiShutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ if err := srv.Shutdown(apiShutdownCtx); err != nil {
+ apiShutdownCancel()
slog.Error("server forced to shutdown", "error", err)
os.Exit(1)
}
+ apiShutdownCancel()
+
+ if metricsServer != nil {
+ metricsShutdownCtx, metricsShutdownCancel := context.WithTimeout(context.Background(), 3*time.Second)
+ if err := metricsServer.Shutdown(metricsShutdownCtx); err != nil {
+ slog.Error("metrics server forced to shutdown", "error", err)
+ }
+ metricsShutdownCancel()
+ }
slog.Info("server stopped")
}
diff --git a/server/cmd/server/metrics_test.go b/server/cmd/server/metrics_test.go
new file mode 100644
index 0000000000..5e06c99978
--- /dev/null
+++ b/server/cmd/server/metrics_test.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/multica-ai/multica/server/internal/analytics"
+ "github.com/multica-ai/multica/server/internal/events"
+ "github.com/multica-ai/multica/server/internal/realtime"
+)
+
+func TestMainRouterDoesNotExposePrometheusMetrics(t *testing.T) {
+ router := NewRouter(nil, realtime.NewHub(), events.New(), analytics.NoopClient{}, nil)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("main API /metrics status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+}
diff --git a/server/cmd/server/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go
index 031cba85b1..aff61f3515 100644
--- a/server/cmd/server/notification_listeners_test.go
+++ b/server/cmd/server/notification_listeners_test.go
@@ -18,9 +18,9 @@ import (
func inboxItemsForRecipient(t *testing.T, queries *db.Queries, recipientID string) []db.ListInboxItemsRow {
t.Helper()
items, err := queries.ListInboxItems(context.Background(), db.ListInboxItemsParams{
- WorkspaceID: util.ParseUUID(testWorkspaceID),
+ WorkspaceID: util.MustParseUUID(testWorkspaceID),
RecipientType: "member",
- RecipientID: util.ParseUUID(recipientID),
+ RecipientID: util.MustParseUUID(recipientID),
})
if err != nil {
t.Fatalf("ListInboxItems: %v", err)
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index 3d6fe24952..b2a9d9e631 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -15,8 +15,10 @@ import (
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/auth"
+ "github.com/multica-ai/multica/server/internal/daemonws"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
+ obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
@@ -62,8 +64,22 @@ func allowedOrigins() []string {
// keeps the default in-memory stores which are fine for single-node dev and
// tests.
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client) chi.Router {
+ return NewRouterWithOptions(pool, hub, bus, analyticsClient, rdb, RouterOptions{})
+}
+
+type RouterOptions struct {
+ HTTPMetrics *obsmetrics.HTTPMetrics
+ DaemonHub *daemonws.Hub
+ DaemonWakeup service.TaskWakeupNotifier
+}
+
+func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client, opts RouterOptions) chi.Router {
queries := db.New(pool)
emailSvc := service.NewEmailService()
+ daemonHub := opts.DaemonHub
+ if daemonHub == nil {
+ daemonHub = daemonws.NewHub()
+ }
// Initialize storage with S3 as primary, fallback to local
var store storage.Storage
@@ -84,7 +100,10 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
}
- h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, analyticsClient, signupConfig)
+ h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, analyticsClient, signupConfig, daemonHub)
+ if opts.DaemonWakeup != nil {
+ h.TaskService.Wakeup = opts.DaemonWakeup
+ }
if rdb != nil {
h.LocalSkillListStore = handler.NewRedisLocalSkillListStore(rdb)
h.LocalSkillImportStore = handler.NewRedisLocalSkillImportStore(rdb)
@@ -97,6 +116,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
r.Use(chimw.RequestID)
r.Use(middleware.ClientMetadata)
r.Use(middleware.RequestLogger)
+ if opts.HTTPMetrics != nil {
+ r.Use(opts.HTTPMetrics.Middleware)
+ }
r.Use(chimw.Recoverer)
r.Use(middleware.ContentSecurityPolicy)
origins := allowedOrigins()
@@ -166,6 +188,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
r.Post("/register", h.DaemonRegister)
r.Post("/deregister", h.DaemonDeregister)
r.Post("/heartbeat", h.DaemonHeartbeat)
+ r.Get("/ws", h.DaemonWebSocket)
r.Get("/workspaces/{workspaceId}/repos", h.GetDaemonWorkspaceRepos)
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
@@ -282,12 +305,26 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
r.Delete("/reactions", h.RemoveIssueReaction)
r.Get("/attachments", h.ListAttachments)
r.Get("/children", h.ListChildIssues)
+ r.Get("/labels", h.ListLabelsForIssue)
+ r.Post("/labels", h.AttachLabel)
+ r.Delete("/labels/{labelId}", h.DetachLabel)
})
})
// Task messages (user-facing, not daemon auth)
r.Get("/api/tasks/{taskId}/messages", h.ListTaskMessagesByUser)
+ // Labels
+ r.Route("/api/labels", func(r chi.Router) {
+ r.Get("/", h.ListLabels)
+ r.Post("/", h.CreateLabel)
+ r.Route("/{id}", func(r chi.Router) {
+ r.Get("/", h.GetLabel)
+ r.Put("/", h.UpdateLabel)
+ r.Delete("/", h.DeleteLabel)
+ })
+ })
+
// Projects
r.Route("/api/projects", func(r chi.Router) {
r.Get("/search", h.SearchProjects)
@@ -470,12 +507,11 @@ func (pr *patResolver) ResolveToken(ctx context.Context, token string) (string,
return util.UUIDToString(pat.UserID), true
}
+// parseUUID is a thin alias for util.MustParseUUID. Call sites here are all
+// internal round-trips of DB-sourced UUIDs (e.g. issue.ID, e.ActorID), so an
+// invalid value indicates a programming error and should panic loudly.
func parseUUID(s string) pgtype.UUID {
- var u pgtype.UUID
- if err := u.Scan(s); err != nil {
- return pgtype.UUID{}
- }
- return u
+ return util.MustParseUUID(s)
}
func splitAndTrim(s string) []string {
diff --git a/server/cmd/server/runtime_sweeper.go b/server/cmd/server/runtime_sweeper.go
index 05ec0f8185..cd38494cae 100644
--- a/server/cmd/server/runtime_sweeper.go
+++ b/server/cmd/server/runtime_sweeper.go
@@ -198,21 +198,10 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, taskSvc *ser
}
}
-// reconcileAgentStatus checks running task count and updates agent status.
+// reconcileAgentStatus refreshes agent status from the current active task set.
// Used only by the test-fallback path of broadcastFailedTasks above.
func reconcileAgentStatus(ctx context.Context, queries *db.Queries, bus *events.Bus, agentID pgtype.UUID) {
- running, err := queries.CountRunningTasks(ctx, agentID)
- if err != nil {
- return
- }
- newStatus := "idle"
- if running > 0 {
- newStatus = "working"
- }
- agent, err := queries.UpdateAgentStatus(ctx, db.UpdateAgentStatusParams{
- ID: agentID,
- Status: newStatus,
- })
+ agent, err := queries.RefreshAgentStatusFromTasks(ctx, agentID)
if err != nil {
return
}
diff --git a/server/cmd/server/runtime_sweeper_test.go b/server/cmd/server/runtime_sweeper_test.go
index f8f4c7c8ef..6d7cd93079 100644
--- a/server/cmd/server/runtime_sweeper_test.go
+++ b/server/cmd/server/runtime_sweeper_test.go
@@ -78,6 +78,49 @@ func cleanupSweeperFixture(t *testing.T, issueID, agentID string) {
testPool.Exec(ctx, `UPDATE agent SET status = 'idle' WHERE id = $1`, agentID)
}
+func TestRefreshAgentStatusFromTasks(t *testing.T) {
+ if testPool == nil {
+ t.Skip("no database connection")
+ }
+
+ ctx := context.Background()
+ issueID, agentID, taskID := setupSweeperTestFixture(t, "dispatched")
+ t.Cleanup(func() { cleanupSweeperFixture(t, issueID, agentID) })
+
+ queries := db.New(testPool)
+
+ if _, err := testPool.Exec(ctx, `UPDATE agent SET status = 'idle' WHERE id = $1`, agentID); err != nil {
+ t.Fatalf("failed to seed idle agent status: %v", err)
+ }
+
+ agent, err := queries.RefreshAgentStatusFromTasks(ctx, parseUUID(agentID))
+ if err != nil {
+ t.Fatalf("RefreshAgentStatusFromTasks with dispatched task failed: %v", err)
+ }
+ if agent.Status != "working" {
+ t.Fatalf("expected dispatched task to refresh agent status to working, got %q", agent.Status)
+ }
+
+ if _, err := testPool.Exec(ctx, `
+ UPDATE agent_task_queue
+ SET status = 'cancelled', completed_at = now()
+ WHERE id = $1
+ `, taskID); err != nil {
+ t.Fatalf("failed to cancel seeded task: %v", err)
+ }
+ if _, err := testPool.Exec(ctx, `UPDATE agent SET status = 'working' WHERE id = $1`, agentID); err != nil {
+ t.Fatalf("failed to reseed working agent status: %v", err)
+ }
+
+ agent, err = queries.RefreshAgentStatusFromTasks(ctx, parseUUID(agentID))
+ if err != nil {
+ t.Fatalf("RefreshAgentStatusFromTasks with no active tasks failed: %v", err)
+ }
+ if agent.Status != "idle" {
+ t.Fatalf("expected cancelled-only task set to refresh agent status to idle, got %q", agent.Status)
+ }
+}
+
// TestSweepStaleTasksBroadcastsWithWorkspaceID verifies that when the task sweeper
// fails a stale running task, the task:failed event is broadcast with the correct
// WorkspaceID so it reaches frontend WebSocket clients (events without WorkspaceID
diff --git a/server/cmd/server/scope_authorizer.go b/server/cmd/server/scope_authorizer.go
index 11e7f551e7..c8033a5262 100644
--- a/server/cmd/server/scope_authorizer.go
+++ b/server/cmd/server/scope_authorizer.go
@@ -5,6 +5,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/realtime"
+ "github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -31,9 +32,12 @@ func (a *dbScopeAuthorizer) AuthorizeScope(ctx context.Context, userID, workspac
if workspaceID == "" || scopeID == "" {
return false, nil
}
- wsUUID := parseUUID(workspaceID)
- idUUID := parseUUID(scopeID)
- if !wsUUID.Valid || !idUUID.Valid {
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ return false, nil
+ }
+ idUUID, err := util.ParseUUID(scopeID)
+ if err != nil {
return false, nil
}
switch scopeType {
@@ -60,8 +64,8 @@ func (a *dbScopeAuthorizer) AuthorizeScope(ctx context.Context, userID, workspac
if sess.WorkspaceID != wsUUID {
return false, nil
}
- uidUUID := parseUUID(userID)
- if !uidUUID.Valid || sess.CreatorID != uidUUID {
+ uidUUID, err := util.ParseUUID(userID)
+ if err != nil || sess.CreatorID != uidUUID {
return false, nil
}
return true, nil
@@ -81,8 +85,8 @@ func (a *dbScopeAuthorizer) AuthorizeScope(ctx context.Context, userID, workspac
// otherwise any workspace member who learns a session_id could
// subscribe to chat:message / chat:done / chat:session_read for a
// peer's private chat.
- uidUUID := parseUUID(userID)
- if !uidUUID.Valid || sess.CreatorID != uidUUID {
+ uidUUID, err := util.ParseUUID(userID)
+ if err != nil || sess.CreatorID != uidUUID {
return false, nil
}
return true, nil
diff --git a/server/cmd/server/subscriber_listeners_test.go b/server/cmd/server/subscriber_listeners_test.go
index 64565315a9..871068004b 100644
--- a/server/cmd/server/subscriber_listeners_test.go
+++ b/server/cmd/server/subscriber_listeners_test.go
@@ -4,7 +4,6 @@ import (
"context"
"testing"
- "github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
"github.com/multica-ai/multica/server/internal/util"
@@ -64,9 +63,9 @@ func cleanupTestUser(t *testing.T, email string) {
func isSubscribed(t *testing.T, queries *db.Queries, issueID, userType, userID string) bool {
t.Helper()
subscribed, err := queries.IsIssueSubscriber(context.Background(), db.IsIssueSubscriberParams{
- IssueID: util.ParseUUID(issueID),
+ IssueID: util.MustParseUUID(issueID),
UserType: userType,
- UserID: util.ParseUUID(userID),
+ UserID: util.MustParseUUID(userID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber: %v", err)
@@ -76,7 +75,7 @@ func isSubscribed(t *testing.T, queries *db.Queries, issueID, userType, userID s
func subscriberCount(t *testing.T, queries *db.Queries, issueID string) int {
t.Helper()
- subs, err := queries.ListIssueSubscribers(context.Background(), util.ParseUUID(issueID))
+ subs, err := queries.ListIssueSubscribers(context.Background(), util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("ListIssueSubscribers: %v", err)
}
@@ -394,11 +393,13 @@ func TestSubscriberIssueCreated_AutopilotMapPayload(t *testing.T) {
}
}
-// Verify parseUUID is consistent — pgtype.UUID from our local helper should match util.ParseUUID
+// Verify parseUUID is consistent — the local helper should agree with util.MustParseUUID
+// for valid input, and panic on invalid input (the silent-zero behavior was removed
+// after #1661 to prevent silent SQL writes against a zero UUID).
func TestParseUUIDConsistency(t *testing.T) {
uuid := "550e8400-e29b-41d4-a716-446655440000"
local := parseUUID(uuid)
- utilResult := util.ParseUUID(uuid)
+ utilResult := util.MustParseUUID(uuid)
if local != utilResult {
t.Fatalf("parseUUID inconsistency: local=%v, util=%v", local, utilResult)
}
@@ -406,9 +407,11 @@ func TestParseUUIDConsistency(t *testing.T) {
t.Fatal("expected valid UUID")
}
- // Empty string should produce invalid UUID
- empty := parseUUID("")
- if empty != (pgtype.UUID{}) {
- t.Fatalf("expected zero UUID for empty string, got %v", empty)
- }
+ // Invalid input (empty string) must panic now — never silently return a zero UUID.
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatal("expected parseUUID(\"\") to panic, but it returned normally")
+ }
+ }()
+ _ = parseUUID("")
}
diff --git a/server/go.mod b/server/go.mod
index 73fb2915f1..70920f7785 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -16,6 +16,7 @@ require (
github.com/jackc/pgx/v5 v5.8.0
github.com/lmittmann/tint v1.1.3
github.com/oklog/ulid/v2 v2.1.1
+ github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0
github.com/resend/resend-go/v2 v2.28.0
github.com/robfig/cron/v3 v3.0.1
@@ -38,14 +39,23 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.uber.org/atomic v1.11.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
)
diff --git a/server/go.sum b/server/go.sum
index b5f8cd6140..10247cfdd1 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -45,6 +47,7 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -56,6 +59,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -70,21 +75,41 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4=
github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -99,12 +124,22 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go
index fbce1698b1..626c2874f0 100644
--- a/server/internal/daemon/config.go
+++ b/server/internal/daemon/config.go
@@ -11,57 +11,60 @@ import (
)
const (
- DefaultServerURL = "ws://localhost:8080/ws"
- DefaultPollInterval = 3 * time.Second
- DefaultHeartbeatInterval = 15 * time.Second
- DefaultAgentTimeout = 2 * time.Hour
- DefaultRuntimeName = "Local Agent"
- DefaultWorkspaceSyncInterval = 30 * time.Second
- DefaultHealthPort = 19514
- DefaultMaxConcurrentTasks = 20
- DefaultGCInterval = 1 * time.Hour
- DefaultGCTTL = 24 * time.Hour // 1 day — AI-coding issues rarely stay open long
- DefaultGCOrphanTTL = 72 * time.Hour // 3 days — orphans with no meta (crashes, pre-GC leftovers)
+ DefaultServerURL = "ws://localhost:8080/ws"
+ DefaultPollInterval = 30 * time.Second
+ DefaultHeartbeatInterval = 15 * time.Second
+ DefaultAgentTimeout = 2 * time.Hour
+ DefaultCodexSemanticInactivityTimeout = 10 * time.Minute
+ DefaultRuntimeName = "Local Agent"
+ DefaultWorkspaceSyncInterval = 30 * time.Second
+ DefaultHealthPort = 19514
+ DefaultMaxConcurrentTasks = 20
+ DefaultGCInterval = 1 * time.Hour
+ DefaultGCTTL = 24 * time.Hour // 1 day — AI-coding issues rarely stay open long
+ DefaultGCOrphanTTL = 72 * time.Hour // 3 days — orphans with no meta (crashes, pre-GC leftovers)
)
// Config holds all daemon configuration.
type Config struct {
- ServerBaseURL string
- DaemonID string
- LegacyDaemonIDs []string // historical daemon_ids this machine may have registered under; reported at register time so the server can merge old runtime rows
- DeviceName string
- RuntimeName string
- CLIVersion string // multica CLI version (e.g. "0.1.13")
- LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
- Profile string // profile name (empty = default)
- Agents map[string]AgentEntry // keyed by provider: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi
- WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
- KeepEnvAfterTask bool // preserve env after task for debugging
- HealthPort int // local HTTP port for health checks (default: 19514)
- MaxConcurrentTasks int // max tasks running in parallel (default: 20)
- GCEnabled bool // enable periodic workspace garbage collection (default: true)
- GCInterval time.Duration // how often the GC loop runs (default: 1h)
- GCTTL time.Duration // clean dirs whose issue is done/canceled and updated_at < now()-TTL (default: 24h)
- GCOrphanTTL time.Duration // clean orphan dirs with no meta older than this (default: 72h). Dirs whose issue returned 404 are cleaned immediately.
- PollInterval time.Duration
- HeartbeatInterval time.Duration
- AgentTimeout time.Duration
+ ServerBaseURL string
+ DaemonID string
+ LegacyDaemonIDs []string // historical daemon_ids this machine may have registered under; reported at register time so the server can merge old runtime rows
+ DeviceName string
+ RuntimeName string
+ CLIVersion string // multica CLI version (e.g. "0.1.13")
+ LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
+ Profile string // profile name (empty = default)
+ Agents map[string]AgentEntry // keyed by provider: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro
+ WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
+ KeepEnvAfterTask bool // preserve env after task for debugging
+ HealthPort int // local HTTP port for health checks (default: 19514)
+ MaxConcurrentTasks int // max tasks running in parallel (default: 20)
+ GCEnabled bool // enable periodic workspace garbage collection (default: true)
+ GCInterval time.Duration // how often the GC loop runs (default: 1h)
+ GCTTL time.Duration // clean dirs whose issue is done/canceled and updated_at < now()-TTL (default: 24h)
+ GCOrphanTTL time.Duration // clean orphan dirs with no meta older than this (default: 72h). Dirs whose issue returned 404 are cleaned immediately.
+ PollInterval time.Duration
+ HeartbeatInterval time.Duration
+ AgentTimeout time.Duration
+ CodexSemanticInactivityTimeout time.Duration
}
// Overrides allows CLI flags to override environment variables and defaults.
// Zero values are ignored and the env/default value is used instead.
type Overrides struct {
- ServerURL string
- WorkspacesRoot string
- PollInterval time.Duration
- HeartbeatInterval time.Duration
- AgentTimeout time.Duration
- MaxConcurrentTasks int
- DaemonID string
- DeviceName string
- RuntimeName string
- Profile string // profile name (empty = default)
- HealthPort int // health check port (0 = use default)
+ ServerURL string
+ WorkspacesRoot string
+ PollInterval time.Duration
+ HeartbeatInterval time.Duration
+ AgentTimeout time.Duration
+ CodexSemanticInactivityTimeout time.Duration
+ MaxConcurrentTasks int
+ DaemonID string
+ DeviceName string
+ RuntimeName string
+ Profile string // profile name (empty = default)
+ HealthPort int // health check port (0 = use default)
}
// LoadConfig builds the daemon configuration from environment variables
@@ -149,8 +152,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")),
}
}
+ kiroPath := envOrDefault("MULTICA_KIRO_PATH", "kiro-cli")
+ if _, err := exec.LookPath(kiroPath); err == nil {
+ agents["kiro"] = AgentEntry{
+ Path: kiroPath,
+ Model: strings.TrimSpace(os.Getenv("MULTICA_KIRO_MODEL")),
+ }
+ }
if len(agents) == 0 {
- return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, or kimi and ensure it is on PATH")
+ return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, kimi, or kiro-cli and ensure it is on PATH")
}
// Host info
@@ -184,6 +194,14 @@ func LoadConfig(overrides Overrides) (Config, error) {
agentTimeout = overrides.AgentTimeout
}
+ codexSemanticInactivityTimeout, err := durationFromEnv("MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT", DefaultCodexSemanticInactivityTimeout)
+ if err != nil {
+ return Config{}, err
+ }
+ if overrides.CodexSemanticInactivityTimeout > 0 {
+ codexSemanticInactivityTimeout = overrides.CodexSemanticInactivityTimeout
+ }
+
maxConcurrentTasks, err := intFromEnv("MULTICA_DAEMON_MAX_CONCURRENT_TASKS", DefaultMaxConcurrentTasks)
if err != nil {
return Config{}, err
@@ -289,24 +307,25 @@ func LoadConfig(overrides Overrides) (Config, error) {
}
return Config{
- ServerBaseURL: serverBaseURL,
- DaemonID: daemonID,
- LegacyDaemonIDs: legacyDaemonIDs,
- DeviceName: deviceName,
- RuntimeName: runtimeName,
- Profile: profile,
- Agents: agents,
- WorkspacesRoot: workspacesRoot,
- KeepEnvAfterTask: keepEnv,
- GCEnabled: gcEnabled,
- GCInterval: gcInterval,
- GCTTL: gcTTL,
- GCOrphanTTL: gcOrphanTTL,
- HealthPort: healthPort,
- MaxConcurrentTasks: maxConcurrentTasks,
- PollInterval: pollInterval,
- HeartbeatInterval: heartbeatInterval,
- AgentTimeout: agentTimeout,
+ ServerBaseURL: serverBaseURL,
+ DaemonID: daemonID,
+ LegacyDaemonIDs: legacyDaemonIDs,
+ DeviceName: deviceName,
+ RuntimeName: runtimeName,
+ Profile: profile,
+ Agents: agents,
+ WorkspacesRoot: workspacesRoot,
+ KeepEnvAfterTask: keepEnv,
+ GCEnabled: gcEnabled,
+ GCInterval: gcInterval,
+ GCTTL: gcTTL,
+ GCOrphanTTL: gcOrphanTTL,
+ HealthPort: healthPort,
+ MaxConcurrentTasks: maxConcurrentTasks,
+ PollInterval: pollInterval,
+ HeartbeatInterval: heartbeatInterval,
+ AgentTimeout: agentTimeout,
+ CodexSemanticInactivityTimeout: codexSemanticInactivityTimeout,
}, nil
}
diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go
index c076e81d91..bbdc78ee15 100644
--- a/server/internal/daemon/daemon.go
+++ b/server/internal/daemon/daemon.go
@@ -45,6 +45,7 @@ type Daemon struct {
workspaces map[string]*workspaceState
runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups
reloading sync.Mutex // prevents concurrent workspace syncs
+ runtimeSetCh chan struct{} // notifies the WS wakeup loop to reconnect with a new runtime set
versionsMu sync.RWMutex // guards agentVersions
agentVersions map[string]string // provider -> detected CLI version (set during registration)
@@ -69,6 +70,7 @@ func New(cfg Config, logger *slog.Logger) *Daemon {
logger: logger,
workspaces: make(map[string]*workspaceState),
runtimeIndex: make(map[string]Runtime),
+ runtimeSetCh: make(chan struct{}, 1),
agentVersions: make(map[string]string),
}
}
@@ -89,6 +91,23 @@ func (d *Daemon) agentVersion(provider string) string {
return d.agentVersions[provider]
}
+func (d *Daemon) notifyRuntimeSetChanged() {
+ select {
+ case d.runtimeSetCh <- struct{}{}:
+ default:
+ }
+}
+
+func (d *Daemon) drainRuntimeSetChanged() {
+ for {
+ select {
+ case <-d.runtimeSetCh:
+ default:
+ return
+ }
+ }
+}
+
// Run starts the daemon: resolves auth, registers runtimes, then polls for tasks.
func (d *Daemon) Run(ctx context.Context) error {
// Wrap context so handleUpdate can cancel the daemon for restart.
@@ -132,10 +151,13 @@ func (d *Daemon) Run(ctx context.Context) error {
// Start workspace sync loop to discover newly created workspaces.
go d.workspaceSyncLoop(ctx)
+ taskWakeups := make(chan struct{}, 1)
+ d.drainRuntimeSetChanged()
+ go d.taskWakeupLoop(ctx, taskWakeups)
go d.heartbeatLoop(ctx)
go d.gcLoop(ctx)
go d.serveHealth(ctx, healthLn, time.Now())
- return d.pollLoop(ctx)
+ return d.pollLoop(ctx, taskWakeups)
}
// RestartBinary returns the path to the new binary if the daemon needs to restart
@@ -422,6 +444,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error {
d.mu.Unlock()
var registered int
+ var removed int
for id, name := range apiIDs {
if currentIDs[id] {
continue // important: never replace existing workspaceState; ensureRepoReady holds ws.repoRefreshMu from the original pointer
@@ -473,8 +496,12 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error {
delete(d.workspaces, id)
d.mu.Unlock()
d.logger.Info("stopped watching workspace", "workspace_id", id)
+ removed++
}
}
+ if registered > 0 || removed > 0 {
+ d.notifyRuntimeSetChanged()
+ }
if len(d.allRuntimeIDs()) == 0 && registered == 0 && len(workspaces) > 0 {
return fmt.Errorf("failed to register runtimes for any of the %d workspace(s)", len(workspaces))
@@ -799,7 +826,7 @@ func (d *Daemon) triggerRestart() {
}
}
-func (d *Daemon) pollLoop(ctx context.Context) error {
+func (d *Daemon) pollLoop(ctx context.Context, taskWakeups <-chan struct{}) error {
sem := make(chan struct{}, d.cfg.MaxConcurrentTasks)
var wg sync.WaitGroup
@@ -822,7 +849,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
runtimeIDs := d.allRuntimeIDs()
if len(runtimeIDs) == 0 {
- if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
+ if err := sleepWithContextOrWakeup(ctx, d.cfg.PollInterval, taskWakeups); err != nil {
wg.Wait()
return err
}
@@ -878,7 +905,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
d.logger.Debug("poll: no tasks", "runtimes", runtimeIDs, "cycle", pollCount)
}
pollOffset = (pollOffset + 1) % n
- if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
+ if err := sleepWithContextOrWakeup(ctx, d.cfg.PollInterval, taskWakeups); err != nil {
wg.Wait()
return err
}
@@ -984,7 +1011,11 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
// have built a real session before getting stuck (rate-limit, tool
// error, etc.) and we want the next chat turn to resume there
// rather than start over and "forget" the conversation.
- if err := d.client.FailTask(ctx, task.ID, result.Comment, result.SessionID, result.WorkDir, "agent_error"); err != nil {
+ failureReason := result.FailureReason
+ if failureReason == "" {
+ failureReason = "agent_error"
+ }
+ if err := d.client.FailTask(ctx, task.ID, result.Comment, result.SessionID, result.WorkDir, failureReason); err != nil {
taskLog.Error("report blocked task failed", "error", err)
}
default:
@@ -1174,12 +1205,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
model = entry.Model
}
execOpts := agent.ExecOptions{
- Cwd: env.WorkDir,
- Model: model,
- Timeout: d.cfg.AgentTimeout,
- ResumeSessionID: task.PriorSessionID,
- CustomArgs: customArgs,
- McpConfig: mcpConfig,
+ Cwd: env.WorkDir,
+ Model: model,
+ Timeout: d.cfg.AgentTimeout,
+ SemanticInactivityTimeout: d.cfg.CodexSemanticInactivityTimeout,
+ ResumeSessionID: task.PriorSessionID,
+ CustomArgs: customArgs,
+ McpConfig: mcpConfig,
}
// openclaw loads its bootstrap files (AGENTS.md, SOUL.md, ...) from its own
// workspace dir rather than the task workdir, so the AGENTS.md written by
@@ -1264,13 +1296,18 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
// in sync even when the agent times out after building a session.
// We mark as "blocked" (not a hard error return) so handleTask
// goes through the FailTask path that forwards session info.
+ comment := result.Error
+ if comment == "" {
+ comment = fmt.Sprintf("%s timed out after %s", provider, d.cfg.AgentTimeout)
+ }
return TaskResult{
- Status: "blocked",
- Comment: fmt.Sprintf("%s timed out after %s", provider, d.cfg.AgentTimeout),
- SessionID: result.SessionID,
- WorkDir: env.WorkDir,
- EnvRoot: env.RootDir,
- Usage: usageEntries,
+ Status: "blocked",
+ Comment: comment,
+ SessionID: result.SessionID,
+ WorkDir: env.WorkDir,
+ EnvRoot: env.RootDir,
+ FailureReason: "timeout",
+ Usage: usageEntries,
}, nil
case "cancelled":
// Server cancelled the task (e.g. issue reassignment, user cancel).
@@ -1363,6 +1400,8 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro
sendCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := d.client.ReportTaskMessages(sendCtx, taskID, toSend); err != nil {
taskLog.Debug("failed to report task messages", "error", err)
+ } else {
+ taskLog.Debug("reported task messages", "count", len(toSend), "last_seq", toSend[len(toSend)-1].Seq)
}
cancel()
}
@@ -1436,6 +1475,7 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro
toolName = callIDToTool[msg.CallID]
mu.Unlock()
}
+ taskLog.Info("tool_result observed", "seq", s, "tool", toolName, "call_id", msg.CallID)
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go
index 9e8ce1093f..33c26ce092 100644
--- a/server/internal/daemon/daemon_test.go
+++ b/server/internal/daemon/daemon_test.go
@@ -10,10 +10,12 @@ import (
"os"
"os/exec"
"path/filepath"
+ "runtime"
"strings"
"sync"
"sync/atomic"
"testing"
+ "time"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
"github.com/multica-ai/multica/server/pkg/agent"
@@ -189,7 +191,7 @@ func TestBuildPromptCommentTriggeredByAgent(t *testing.T) {
for _, want := range []string{
"Another agent (Atlas)",
"do not @mention the other agent as a sign-off",
- "silence is the preferred way",
+ "Silence is the preferred way",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q\n---\n%s", want, prompt)
@@ -421,6 +423,97 @@ func TestExecuteAndDrain_NoRetryWhenSessionEstablished(t *testing.T) {
}
}
+func TestExecuteAndDrain_CodexInactivityReportsToolResultTranscript(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("shell-script fixture is POSIX-only")
+ }
+
+ fakePath := filepath.Join(t.TempDir(), "codex")
+ script := "#!/bin/sh\n" +
+ `read line` + "\n" +
+ `echo '{"jsonrpc":"2.0","id":1,"result":{}}'` + "\n" +
+ `read line` + "\n" +
+ `read line` + "\n" +
+ `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-drain"}}}'` + "\n" +
+ `read line` + "\n" +
+ `echo '{"jsonrpc":"2.0","id":3,"result":{}}'` + "\n" +
+ `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-drain","turn":{"id":"turn-drain"}}}'` + "\n" +
+ `echo '{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","command":"git status"}}}'` + "\n" +
+ `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"clean"}}}'` + "\n" +
+ `sleep 5` + "\n"
+ if err := os.WriteFile(fakePath, []byte(script), 0o755); err != nil {
+ t.Fatalf("write fake codex: %v", err)
+ }
+ if err := os.Chmod(fakePath, 0o755); err != nil {
+ t.Fatalf("chmod fake codex: %v", err)
+ }
+
+ var mu sync.Mutex
+ var reported []TaskMessageData
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/daemon/tasks/task-stale/messages" {
+ http.NotFound(w, r)
+ return
+ }
+ var body struct {
+ Messages []TaskMessageData `json:"messages"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("decode task messages: %v", err)
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ mu.Lock()
+ reported = append(reported, body.Messages...)
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(srv.Close)
+
+ backend, err := agent.New("codex", agent.Config{ExecutablePath: fakePath, Logger: slog.Default()})
+ if err != nil {
+ t.Fatalf("new codex backend: %v", err)
+ }
+ d := &Daemon{client: NewClient(srv.URL), logger: slog.Default()}
+ result, tools, err := d.executeAndDrain(context.Background(), backend, "prompt", agent.ExecOptions{
+ Timeout: 5 * time.Second,
+ SemanticInactivityTimeout: 100 * time.Millisecond,
+ }, slog.Default(), "task-stale")
+ if err != nil {
+ t.Fatalf("executeAndDrain: %v", err)
+ }
+ if result.Status != "timeout" {
+ t.Fatalf("expected timeout, got status=%q error=%q", result.Status, result.Error)
+ }
+ if tools != 1 {
+ t.Fatalf("expected one tool use, got %d", tools)
+ }
+
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ mu.Lock()
+ var gotToolUse, gotToolResult bool
+ for _, msg := range reported {
+ if msg.Seq == 1 && msg.Type == "tool_use" && msg.Tool == "exec_command" {
+ gotToolUse = true
+ }
+ if msg.Seq == 2 && msg.Type == "tool_result" && msg.Tool == "exec_command" && msg.Output == "clean" {
+ gotToolResult = true
+ }
+ }
+ mu.Unlock()
+ if gotToolUse && gotToolResult {
+ return
+ }
+ if time.Now().After(deadline) {
+ mu.Lock()
+ defer mu.Unlock()
+ t.Fatalf("expected tool_use seq=1 and tool_result seq=2 in transcript, got %+v", reported)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
// blockingBackend returns a Session whose Result channel is never written to,
// so executeAndDrain can only exit via the drainCtx.Done() path.
type blockingBackend struct{}
diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go
index be481ceefc..cf397b4245 100644
--- a/server/internal/daemon/execenv/context.go
+++ b/server/internal/daemon/execenv/context.go
@@ -18,6 +18,7 @@ import (
// Pi: skills → {workDir}/.pi/skills/{name}/SKILL.md (native discovery)
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
+// Kiro: skills → {workDir}/.kiro/skills/{name}/SKILL.md (native discovery)
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
contextDir := filepath.Join(workDir, ".agent_context")
@@ -74,6 +75,10 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
// Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
// in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
skillsDir = filepath.Join(workDir, ".kimi", "skills")
+ case "kiro":
+ // Kiro CLI auto-discovers project-level skills from .kiro/skills/
+ // in the workdir.
+ skillsDir = filepath.Join(workDir, ".kiro", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go
index c05e96d0c4..a69877a580 100644
--- a/server/internal/daemon/execenv/execenv.go
+++ b/server/internal/daemon/execenv/execenv.go
@@ -24,7 +24,7 @@ type PrepareParams struct {
WorkspaceID string // workspace UUID — tasks are grouped under this
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
- Provider string // agent provider ("claude", "codex") — determines skill injection paths
+ Provider string // agent provider (determines runtime config and skill injection paths)
CodexVersion string // detected Codex CLI version (only used when Provider == "codex")
Task TaskContextForEnv // context data for writing files
}
diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go
index b65050ec51..515a73a27a 100644
--- a/server/internal/daemon/execenv/execenv_test.go
+++ b/server/internal/daemon/execenv/execenv_test.go
@@ -619,6 +619,33 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
}
}
+func TestWriteContextFilesKiroNativeSkills(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+
+ ctx := TaskContextForEnv{
+ IssueID: "kiro-skill-test",
+ AgentSkills: []SkillContextForEnv{
+ {Name: "Go Conventions", Content: "Follow Go conventions."},
+ },
+ }
+
+ if err := writeContextFiles(dir, "kiro", ctx); err != nil {
+ t.Fatalf("writeContextFiles failed: %v", err)
+ }
+
+ skillMd, err := os.ReadFile(filepath.Join(dir, ".kiro", "skills", "go-conventions", "SKILL.md"))
+ if err != nil {
+ t.Fatalf("failed to read .kiro/skills/go-conventions/SKILL.md: %v", err)
+ }
+ if !strings.Contains(string(skillMd), "Follow Go conventions.") {
+ t.Error("SKILL.md missing content")
+ }
+ if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) {
+ t.Error("expected .agent_context/skills/ to NOT exist for Kiro provider")
+ }
+}
+
func TestInjectRuntimeConfigOpencode(t *testing.T) {
t.Parallel()
dir := t.TempDir()
@@ -655,6 +682,36 @@ func TestInjectRuntimeConfigOpencode(t *testing.T) {
}
}
+func TestInjectRuntimeConfigKiro(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+
+ ctx := TaskContextForEnv{
+ IssueID: "test-issue-id",
+ AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
+ }
+
+ if err := InjectRuntimeConfig(dir, "kiro", ctx); err != nil {
+ t.Fatalf("InjectRuntimeConfig failed: %v", err)
+ }
+
+ content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
+ if err != nil {
+ t.Fatalf("failed to read AGENTS.md: %v", err)
+ }
+
+ s := string(content)
+ if !strings.Contains(s, "Multica Agent Runtime") {
+ t.Error("AGENTS.md missing meta skill header")
+ }
+ if !strings.Contains(s, "Coding") {
+ t.Error("AGENTS.md missing skill name")
+ }
+ if !strings.Contains(s, "discovered automatically") {
+ t.Error("AGENTS.md missing native skill discovery hint")
+ }
+}
+
func TestPrepareWithRepoContextOpencode(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
@@ -771,6 +828,39 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
}
}
+// TestInjectRuntimeConfigDirectsMultiLineWritesToStdin pins the guidance that
+// any multi-line content for `multica issue comment add` must go through
+// `--content-stdin` + a HEREDOC. Agents that reached for the inline
+// `--content "...\n\n..."` form ended up with literal 4-char `\n` sequences
+// in stored comments because bash does not expand backslash escapes inside
+// double quotes; see MUL-1467. This test prevents the multi-line guidance
+// from silently regressing back into a "for special characters" footnote.
+func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ if err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
+ t.Fatalf("InjectRuntimeConfig failed: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
+ if err != nil {
+ t.Fatalf("read CLAUDE.md: %v", err)
+ }
+ s := string(data)
+
+ for _, want := range []string{
+ "multi-line content",
+ "MUST pipe via stdin",
+ "--content-stdin",
+ "<<'COMMENT'",
+ "`--description`",
+ "--description-stdin",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("CLAUDE.md missing multi-line guidance %q\n---\n%s", want, s)
+ }
+ }
+}
+
func TestInjectRuntimeConfigAutopilotRunOnlyNoIssueWorkflow(t *testing.T) {
t.Parallel()
dir := t.TempDir()
diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go
index f49295874f..38785dbfa3 100644
--- a/server/internal/daemon/execenv/runtime_config.go
+++ b/server/internal/daemon/execenv/runtime_config.go
@@ -20,13 +20,14 @@ import (
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from .pi/skills/)
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
+// For Kiro: writes {workDir}/AGENTS.md (Kiro CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(provider, ctx)
switch provider {
case "claude":
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
- case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi":
+ case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro":
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
case "gemini":
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
@@ -86,7 +87,17 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent ] [--status X]` — Create a new issue\n")
b.WriteString("- `multica issue assign --to ` — Assign an issue to a member or agent by name (use --unassign to remove assignee)\n")
b.WriteString("- `multica issue comment add --content \"...\" [--parent ]` — Post a comment (use --parent to reply to a specific comment)\n")
- b.WriteString(" - For content with special characters (backticks, quotes), pipe via stdin: `cat <<'COMMENT' | multica issue comment add --content-stdin`\n")
+ b.WriteString(" - **For multi-line content (anything with line breaks, paragraphs, code blocks, backticks, or quotes), you MUST pipe via stdin** — bash does NOT expand `\\n` inside double quotes, so writing `--content \"para1\\n\\npara2\"` stores the literal 4-char sequence and the comment renders without line breaks. Use a HEREDOC instead:\n")
+ b.WriteString("\n")
+ b.WriteString(" ```\n")
+ b.WriteString(" cat <<'COMMENT' | multica issue comment add --content-stdin\n")
+ b.WriteString(" First paragraph.\n")
+ b.WriteString("\n")
+ b.WriteString(" Second paragraph with `code` and \"quotes\".\n")
+ b.WriteString(" COMMENT\n")
+ b.WriteString(" ```\n")
+ b.WriteString("\n")
+ b.WriteString(" - The same rule applies to `--description` on `multica issue create` and `multica issue update` — use `--description-stdin` and pipe a HEREDOC for any multi-line description; the inline `--description \"...\"` form is for short single-line text only.\n")
b.WriteString("- `multica issue comment delete ` — Delete a comment\n")
b.WriteString("- `multica issue status ` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update [--title X] [--description X] [--priority X]` — Update issue fields\n")
@@ -158,7 +169,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since ` to fetch only recent ones\n")
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
- b.WriteString("4. **Decide whether a reply is warranted.** If the triggering comment is an acknowledgment / thanks / sign-off from another agent and no concrete question or task is being asked of you, do NOT post a reply — just exit. Silence is a valid and preferred way to end agent-to-agent conversations.\n")
+ b.WriteString("4. **Decide whether a reply is warranted.** If you produced actual work this turn (investigated, fixed, answered a real question), post the result via step 6 — that is a normal reply, not a noise comment. If the triggering comment was a pure acknowledgment / thanks / sign-off from another agent AND you produced no work this turn, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n")
b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n")
b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ")
b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID))
@@ -181,8 +192,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
case "claude":
// Claude discovers skills natively from .claude/skills/ — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
- case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi":
- // Codex, Copilot, OpenCode, OpenClaw, Pi, Cursor, and Kimi discover skills natively from their respective paths — just list names.
+ case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi", "kiro":
+ // Codex, Copilot, OpenCode, OpenClaw, Pi, Cursor, Kimi, and Kiro discover skills natively from their respective paths — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "gemini", "hermes":
// Gemini reads GEMINI.md directly; Hermes has no native skills discovery path
diff --git a/server/internal/daemon/helpers.go b/server/internal/daemon/helpers.go
index 2e93ed7ed1..4aa81781a6 100644
--- a/server/internal/daemon/helpers.go
+++ b/server/internal/daemon/helpers.go
@@ -78,3 +78,21 @@ func sleepWithContext(ctx context.Context, d time.Duration) error {
return nil
}
}
+
+func sleepWithContextOrWakeup(ctx context.Context, d time.Duration, wakeups <-chan struct{}) error {
+ if wakeups == nil {
+ return sleepWithContext(ctx, d)
+ }
+
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-wakeups:
+ return nil
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/server/internal/daemon/local_skills.go b/server/internal/daemon/local_skills.go
index f51ee65010..ffb61f4b57 100644
--- a/server/internal/daemon/local_skills.go
+++ b/server/internal/daemon/local_skills.go
@@ -46,6 +46,7 @@ type runtimeLocalSkillBundle struct {
// - Pi: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md
// - Cursor: official forum guidance referencing the built-in /create-skill flow
// (https://forum.cursor.com/t/cursor-doesnt-know-new-skills-arens-saved/158507)
+// - Kiro: project and user-level .kiro/skills directories discovered by Kiro CLI
//
// Longer-term this mapping would be better colocated with the provider
// definitions under server/pkg/agent so adding a new runtime can't silently
@@ -75,6 +76,8 @@ func localSkillRootForProvider(provider string) (string, bool, error) {
return filepath.Join(home, ".pi", "agent", "skills"), true, nil
case "cursor":
return filepath.Join(home, ".cursor", "skills"), true, nil
+ case "kiro":
+ return filepath.Join(home, ".kiro", "skills"), true, nil
default:
return "", false, nil
}
diff --git a/server/internal/daemon/local_skills_test.go b/server/internal/daemon/local_skills_test.go
index 96988f1172..6de9532f85 100644
--- a/server/internal/daemon/local_skills_test.go
+++ b/server/internal/daemon/local_skills_test.go
@@ -70,6 +70,35 @@ func TestListRuntimeLocalSkills_Claude(t *testing.T) {
}
}
+func TestListRuntimeLocalSkills_Kiro(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ writeTestLocalSkill(t, filepath.Join(home, ".kiro", "skills"), "review-helper", map[string]string{
+ "SKILL.md": "---\nname: Kiro Review\ndescription: Review code with Kiro\n---\n# Kiro Review\n",
+ })
+
+ skills, supported, err := listRuntimeLocalSkills("kiro")
+ if err != nil {
+ t.Fatalf("listRuntimeLocalSkills: %v", err)
+ }
+ if !supported {
+ t.Fatal("kiro should be supported")
+ }
+ if len(skills) != 1 {
+ t.Fatalf("expected 1 skill, got %d", len(skills))
+ }
+ if skills[0].Key != "review-helper" {
+ t.Fatalf("key = %q, want review-helper", skills[0].Key)
+ }
+ if skills[0].Name != "Kiro Review" {
+ t.Fatalf("name = %q, want Kiro Review", skills[0].Name)
+ }
+ if skills[0].SourcePath != "~/.kiro/skills/review-helper" {
+ t.Fatalf("source_path = %q", skills[0].SourcePath)
+ }
+}
+
// Skill installers (for example lark-cli) place every skill at a shared
// location like ~/.agents/skills/ and symlink each one into the
// runtime root (~/.claude/skills/). The previous filepath.WalkDir
@@ -180,8 +209,8 @@ func TestListRuntimeLocalSkills_DescendsIntoNestedSkillDirs(t *testing.T) {
// Top-level skill — should register at key="top" and its child SKILL.md
// must NOT register as a separate skill.
writeTestLocalSkill(t, root, "top", map[string]string{
- "SKILL.md": "---\nname: Top\n---\n",
- "templates/SKILL.md": "not a real skill — sub-template that happens to share the filename",
+ "SKILL.md": "---\nname: Top\n---\n",
+ "templates/SKILL.md": "not a real skill — sub-template that happens to share the filename",
})
// Nested skill — only valid SKILL.md is at depth 2.
diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go
index e56a9db37b..24dc266fdf 100644
--- a/server/internal/daemon/prompt.go
+++ b/server/internal/daemon/prompt.go
@@ -49,7 +49,7 @@ func buildCommentPrompt(task Task) string {
fmt.Fprintf(&b, "[NEW COMMENT] %s just left a new comment. Focus on THIS comment — do not confuse it with previous ones:\n\n", authorLabel)
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
if task.TriggerAuthorType == "agent" {
- b.WriteString("⚠️ The triggering comment was posted by another agent. Before replying, decide whether a reply is warranted at all. If that comment was an acknowledgment, thanks, or sign-off and no concrete question or task is being asked of you, do NOT reply — silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n")
+ b.WriteString("⚠️ The triggering comment was posted by another agent. Decide whether a reply is warranted. If you produced actual work this turn (investigated, fixed something, answered a real question), post the result as a normal reply — that is NOT a noise comment, and the standard rule that final results must be delivered via comment still applies. If the triggering comment was a pure acknowledgment, thanks, or sign-off AND you produced no work this turn, do NOT reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n")
}
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID)
diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go
index dba4a9f0ec..2c64e9cae4 100644
--- a/server/internal/daemon/types.go
+++ b/server/internal/daemon/types.go
@@ -85,12 +85,13 @@ type TaskUsageEntry struct {
// TaskResult is the outcome of executing a task.
type TaskResult struct {
- Status string `json:"status"`
- Comment string `json:"comment"`
- BranchName string `json:"branch_name,omitempty"`
- EnvType string `json:"env_type,omitempty"`
- SessionID string `json:"session_id,omitempty"` // Claude session ID for future resumption
- WorkDir string `json:"work_dir,omitempty"` // working directory used during execution
- EnvRoot string `json:"-"` // env root dir for writing GC metadata (not sent to server)
- Usage []TaskUsageEntry `json:"usage,omitempty"` // per-model token usage
+ Status string `json:"status"`
+ Comment string `json:"comment"`
+ BranchName string `json:"branch_name,omitempty"`
+ EnvType string `json:"env_type,omitempty"`
+ SessionID string `json:"session_id,omitempty"` // Claude session ID for future resumption
+ WorkDir string `json:"work_dir,omitempty"` // working directory used during execution
+ EnvRoot string `json:"-"` // env root dir for writing GC metadata (not sent to server)
+ FailureReason string `json:"-"` // internal server failure classification
+ Usage []TaskUsageEntry `json:"usage,omitempty"` // per-model token usage
}
diff --git a/server/internal/daemon/wakeup.go b/server/internal/daemon/wakeup.go
new file mode 100644
index 0000000000..9ab2c58fb1
--- /dev/null
+++ b/server/internal/daemon/wakeup.go
@@ -0,0 +1,188 @@
+package daemon
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/multica-ai/multica/server/pkg/protocol"
+)
+
+var errRuntimeSetChanged = errors.New("runtime set changed")
+
+func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- struct{}) {
+ backoff := time.Second
+
+ for {
+ runtimeIDs := d.allRuntimeIDs()
+ if len(runtimeIDs) == 0 {
+ if err := sleepWithContextOrRuntimeChange(ctx, 5*time.Second, d.runtimeSetCh); err != nil {
+ return
+ }
+ continue
+ }
+
+ err := d.runTaskWakeupConnection(ctx, runtimeIDs, taskWakeups)
+ if ctx.Err() != nil {
+ return
+ }
+ if errors.Is(err, errRuntimeSetChanged) {
+ backoff = time.Second
+ continue
+ }
+ if err != nil {
+ d.logger.Debug("task wakeup websocket unavailable; polling fallback remains active", "error", err, "retry_in", backoff)
+ }
+
+ if err := sleepWithContextOrRuntimeChange(ctx, jitterDuration(backoff), d.runtimeSetCh); err != nil {
+ return
+ }
+ if backoff < 30*time.Second {
+ backoff *= 2
+ if backoff > 30*time.Second {
+ backoff = 30 * time.Second
+ }
+ }
+ }
+}
+
+func jitterDuration(d time.Duration) time.Duration {
+ if d <= 0 {
+ return d
+ }
+ spread := d / 5
+ if spread <= 0 {
+ return d
+ }
+ delta := time.Duration(rand.Int63n(int64(spread)*2+1)) - spread
+ return d + delta
+}
+
+func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []string, taskWakeups chan<- struct{}) error {
+ wsURL, err := taskWakeupURL(d.cfg.ServerBaseURL, runtimeIDs)
+ if err != nil {
+ return err
+ }
+
+ headers := http.Header{}
+ if token := d.client.Token(); token != "" {
+ headers.Set("Authorization", "Bearer "+token)
+ }
+ if d.client.platform != "" {
+ headers.Set("X-Client-Platform", d.client.platform)
+ }
+ if d.client.version != "" {
+ headers.Set("X-Client-Version", d.client.version)
+ }
+ if d.client.os != "" {
+ headers.Set("X-Client-OS", d.client.os)
+ }
+
+ dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
+ conn, _, err := dialer.DialContext(ctx, wsURL, headers)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ d.logger.Info("task wakeup websocket connected", "runtimes", len(runtimeIDs))
+ signalTaskWakeup(taskWakeups)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- d.readTaskWakeupMessages(conn, taskWakeups)
+ }()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-d.runtimeSetCh:
+ return errRuntimeSetChanged
+ case err := <-errCh:
+ return err
+ }
+}
+
+func (d *Daemon) readTaskWakeupMessages(conn *websocket.Conn, taskWakeups chan<- struct{}) error {
+ conn.SetReadLimit(64 * 1024)
+ for {
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ return err
+ }
+ var msg protocol.Message
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ d.logger.Debug("task wakeup websocket invalid message", "error", err)
+ continue
+ }
+ if msg.Type != protocol.EventDaemonTaskAvailable {
+ continue
+ }
+ var payload protocol.TaskAvailablePayload
+ if len(msg.Payload) > 0 {
+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
+ d.logger.Debug("task wakeup websocket invalid payload", "error", err)
+ continue
+ }
+ }
+ if payload.RuntimeID != "" {
+ d.logger.Debug("task wakeup received", "runtime_id", payload.RuntimeID, "task_id", payload.TaskID)
+ }
+ signalTaskWakeup(taskWakeups)
+ }
+}
+
+func signalTaskWakeup(taskWakeups chan<- struct{}) {
+ select {
+ case taskWakeups <- struct{}{}:
+ default:
+ }
+}
+
+func taskWakeupURL(baseURL string, runtimeIDs []string) (string, error) {
+ u, err := url.Parse(strings.TrimSpace(baseURL))
+ if err != nil {
+ return "", fmt.Errorf("invalid daemon server URL: %w", err)
+ }
+ switch u.Scheme {
+ case "http":
+ u.Scheme = "ws"
+ case "https":
+ u.Scheme = "wss"
+ case "ws", "wss":
+ default:
+ return "", fmt.Errorf("daemon server URL must use http, https, ws, or wss")
+ }
+
+ u.Path = strings.TrimRight(u.Path, "/") + "/api/daemon/ws"
+ u.RawPath = ""
+ q := u.Query()
+ ids := append([]string(nil), runtimeIDs...)
+ sort.Strings(ids)
+ q.Set("runtime_ids", strings.Join(ids, ","))
+ u.RawQuery = q.Encode()
+ u.Fragment = ""
+ return u.String(), nil
+}
+
+func sleepWithContextOrRuntimeChange(ctx context.Context, d time.Duration, runtimeSetCh <-chan struct{}) error {
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-runtimeSetCh:
+ return nil
+ case <-timer.C:
+ return nil
+ }
+}
diff --git a/server/internal/daemon/wakeup_test.go b/server/internal/daemon/wakeup_test.go
new file mode 100644
index 0000000000..ce9960fc0b
--- /dev/null
+++ b/server/internal/daemon/wakeup_test.go
@@ -0,0 +1,43 @@
+package daemon
+
+import "testing"
+
+func TestTaskWakeupURL(t *testing.T) {
+ tests := []struct {
+ name string
+ baseURL string
+ runtimeIDs []string
+ want string
+ }{
+ {
+ name: "http base",
+ baseURL: "http://localhost:8080",
+ runtimeIDs: []string{"runtime-b", "runtime-a"},
+ want: "ws://localhost:8080/api/daemon/ws?runtime_ids=runtime-a%2Cruntime-b",
+ },
+ {
+ name: "https base",
+ baseURL: "https://api.example.com",
+ runtimeIDs: []string{"runtime-1"},
+ want: "wss://api.example.com/api/daemon/ws?runtime_ids=runtime-1",
+ },
+ {
+ name: "base path",
+ baseURL: "https://api.example.com/multica",
+ runtimeIDs: []string{"runtime-1"},
+ want: "wss://api.example.com/multica/api/daemon/ws?runtime_ids=runtime-1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := taskWakeupURL(tt.baseURL, tt.runtimeIDs)
+ if err != nil {
+ t.Fatalf("taskWakeupURL: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("taskWakeupURL() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/server/internal/daemonws/hub.go b/server/internal/daemonws/hub.go
new file mode 100644
index 0000000000..eeecdacc4b
--- /dev/null
+++ b/server/internal/daemonws/hub.go
@@ -0,0 +1,349 @@
+package daemonws
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/multica-ai/multica/server/pkg/protocol"
+)
+
+const (
+ writeWait = 10 * time.Second
+ pongWait = 60 * time.Second
+ pingPeriod = (pongWait * 9) / 10
+)
+
+// ClientIdentity captures the already-authenticated daemon connection scope.
+type ClientIdentity struct {
+ DaemonID string
+ UserID string
+ WorkspaceID string
+ RuntimeIDs []string
+ ClientVersion string
+}
+
+type client struct {
+ hub *Hub
+ conn *websocket.Conn
+ send chan []byte
+ identity ClientIdentity
+ runtimes map[string]struct{}
+
+ dedupMu sync.Mutex
+ seenIDs map[string]struct{}
+ seenList []string
+}
+
+const eventDedupCapacity = 128
+
+// markSeen records eventID as already delivered to this client. Empty event IDs
+// disable dedup and are always delivered.
+func (c *client) markSeen(eventID string) bool {
+ if eventID == "" {
+ return true
+ }
+ c.dedupMu.Lock()
+ defer c.dedupMu.Unlock()
+ if c.seenIDs == nil {
+ c.seenIDs = make(map[string]struct{}, eventDedupCapacity)
+ }
+ if _, ok := c.seenIDs[eventID]; ok {
+ return false
+ }
+ c.seenIDs[eventID] = struct{}{}
+ c.seenList = append(c.seenList, eventID)
+ if len(c.seenList) > eventDedupCapacity {
+ drop := c.seenList[0]
+ c.seenList = c.seenList[1:]
+ delete(c.seenIDs, drop)
+ }
+ return true
+}
+
+// Hub keeps daemon WebSocket connections indexed by runtime ID. Messages are
+// best-effort wakeup hints; the daemon still uses HTTP claim for correctness.
+type Hub struct {
+ upgrader websocket.Upgrader
+
+ mu sync.RWMutex
+ clients map[*client]bool
+ byRuntime map[string]map[*client]bool
+}
+
+func NewHub() *Hub {
+ return &Hub{
+ upgrader: websocket.Upgrader{
+ // Daemon clients authenticate with Authorization headers before the
+ // upgrade. Browsers cannot set those headers through the native WS API,
+ // and DaemonAuth does not accept cookies, so cookie-based CSWSH does
+ // not apply to this endpoint. Re-evaluate this if DaemonAuth ever
+ // grows cookie fallback.
+ CheckOrigin: func(r *http.Request) bool { return true },
+ },
+ clients: make(map[*client]bool),
+ byRuntime: make(map[string]map[*client]bool),
+ }
+}
+
+func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, identity ClientIdentity) {
+ if len(identity.RuntimeIDs) == 0 {
+ http.Error(w, `{"error":"runtime_ids required"}`, http.StatusBadRequest)
+ return
+ }
+
+ conn, err := h.upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ slog.Error("daemon websocket upgrade failed", "error", err)
+ return
+ }
+
+ runtimes := make(map[string]struct{}, len(identity.RuntimeIDs))
+ for _, runtimeID := range identity.RuntimeIDs {
+ if runtimeID != "" {
+ runtimes[runtimeID] = struct{}{}
+ }
+ }
+ if len(runtimes) == 0 {
+ conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"runtime_ids required"}`))
+ conn.Close()
+ return
+ }
+
+ c := &client{
+ hub: h,
+ conn: conn,
+ send: make(chan []byte, 16),
+ identity: identity,
+ runtimes: runtimes,
+ }
+ h.register(c)
+
+ go c.writePump()
+ go c.readPump()
+}
+
+// NotifyTaskAvailable sends a best-effort wakeup to daemons watching runtimeID.
+func (h *Hub) NotifyTaskAvailable(runtimeID, taskID string) {
+ h.notifyTaskAvailable(runtimeID, taskID, "")
+}
+
+func (h *Hub) notifyTaskAvailable(runtimeID, taskID, eventID string) {
+ if h == nil || runtimeID == "" {
+ return
+ }
+ data, err := taskAvailableFrame(runtimeID, taskID)
+ if err != nil {
+ return
+ }
+ delivered, deduped := h.notifyFrame(runtimeID, data, eventID)
+ if delivered {
+ M.WakeupDeliveredHit.Add(1)
+ } else if !deduped {
+ M.WakeupDeliveredMiss.Add(1)
+ }
+}
+
+func (h *Hub) DeliverDaemonRuntime(scopeID string, frame []byte, eventID string) {
+ if h == nil {
+ return
+ }
+ M.WakeupReceivedTotal.Add(1)
+ var msg protocol.Message
+ if err := json.Unmarshal(frame, &msg); err != nil {
+ slog.Debug("daemon websocket relay: invalid frame", "error", err, "scope_id", scopeID, "event_id", eventID)
+ M.WakeupDeliveredMiss.Add(1)
+ return
+ }
+ if msg.Type != protocol.EventDaemonTaskAvailable {
+ M.WakeupDeliveredMiss.Add(1)
+ return
+ }
+ var payload protocol.TaskAvailablePayload
+ if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.RuntimeID == "" {
+ slog.Debug("daemon websocket relay: invalid task_available payload", "error", err, "scope_id", scopeID, "event_id", eventID)
+ M.WakeupDeliveredMiss.Add(1)
+ return
+ }
+ delivered, deduped := h.notifyFrame(payload.RuntimeID, frame, eventID)
+ if delivered {
+ M.WakeupDeliveredHit.Add(1)
+ } else if !deduped {
+ M.WakeupDeliveredMiss.Add(1)
+ }
+}
+
+func (h *Hub) notifyFrame(runtimeID string, data []byte, eventID string) (delivered bool, deduped bool) {
+ h.mu.RLock()
+ clients := h.byRuntime[runtimeID]
+ slow := make([]*client, 0)
+ for c := range clients {
+ if !c.markSeen(eventID) {
+ deduped = true
+ continue
+ }
+ select {
+ case c.send <- data:
+ delivered = true
+ default:
+ slow = append(slow, c)
+ }
+ }
+ h.mu.RUnlock()
+
+ for _, c := range slow {
+ h.unregister(c)
+ c.conn.Close()
+ }
+ if len(slow) > 0 {
+ M.SlowEvictionsTotal.Add(int64(len(slow)))
+ }
+ return delivered, deduped
+}
+
+func taskAvailableFrame(runtimeID, taskID string) ([]byte, error) {
+ return json.Marshal(protocol.Message{
+ Type: protocol.EventDaemonTaskAvailable,
+ Payload: mustMarshalRaw(protocol.TaskAvailablePayload{
+ RuntimeID: runtimeID,
+ TaskID: taskID,
+ }),
+ })
+}
+
+func mustMarshalRaw(v any) json.RawMessage {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return nil
+ }
+ return data
+}
+
+func (h *Hub) RuntimeConnectionCount(runtimeID string) int {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ return len(h.byRuntime[runtimeID])
+}
+
+func (h *Hub) register(c *client) {
+ h.mu.Lock()
+ h.clients[c] = true
+ for runtimeID := range c.runtimes {
+ conns := h.byRuntime[runtimeID]
+ if conns == nil {
+ conns = make(map[*client]bool)
+ h.byRuntime[runtimeID] = conns
+ }
+ conns[c] = true
+ }
+ total := len(h.clients)
+ h.mu.Unlock()
+
+ M.ConnectsTotal.Add(1)
+ M.ActiveConnections.Add(1)
+ slog.Info("daemon websocket connected",
+ "daemon_id", c.identity.DaemonID,
+ "user_id", c.identity.UserID,
+ "workspace_id", c.identity.WorkspaceID,
+ "runtimes", len(c.runtimes),
+ "client_version", c.identity.ClientVersion,
+ "total_clients", total,
+ )
+}
+
+func (h *Hub) unregister(c *client) {
+ h.mu.Lock()
+ if !h.clients[c] {
+ h.mu.Unlock()
+ return
+ }
+ delete(h.clients, c)
+ for runtimeID := range c.runtimes {
+ if conns := h.byRuntime[runtimeID]; conns != nil {
+ delete(conns, c)
+ if len(conns) == 0 {
+ delete(h.byRuntime, runtimeID)
+ }
+ }
+ }
+ close(c.send)
+ total := len(h.clients)
+ h.mu.Unlock()
+
+ M.DisconnectsTotal.Add(1)
+ M.ActiveConnections.Add(-1)
+ slog.Info("daemon websocket disconnected",
+ "daemon_id", c.identity.DaemonID,
+ "user_id", c.identity.UserID,
+ "workspace_id", c.identity.WorkspaceID,
+ "runtimes", len(c.runtimes),
+ "total_clients", total,
+ )
+}
+
+func (c *client) readPump() {
+ defer func() {
+ c.hub.unregister(c)
+ c.conn.Close()
+ }()
+
+ c.conn.SetReadLimit(4096)
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(string) error {
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ return nil
+ })
+
+ for {
+ _, raw, err := c.conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
+ slog.Debug("daemon websocket read error", "error", err, "daemon_id", c.identity.DaemonID)
+ }
+ return
+ }
+ c.handleFrame(raw)
+ }
+}
+
+func (c *client) handleFrame(raw []byte) {
+ var msg protocol.Message
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ slog.Debug("daemon websocket invalid frame", "error", err, "daemon_id", c.identity.DaemonID)
+ return
+ }
+ // The phase-one daemon channel is server-push only. Inbound frames are
+ // drained so control frames and close handling work, but app messages are
+ // intentionally ignored for forward compatibility.
+}
+
+func (c *client) writePump() {
+ ticker := time.NewTicker(pingPeriod)
+ defer func() {
+ ticker.Stop()
+ c.conn.Close()
+ }()
+
+ for {
+ select {
+ case message, ok := <-c.send:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if !ok {
+ c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+ if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
+ slog.Debug("daemon websocket write error", "error", err, "daemon_id", c.identity.DaemonID)
+ return
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ }
+ }
+}
diff --git a/server/internal/daemonws/hub_test.go b/server/internal/daemonws/hub_test.go
new file mode 100644
index 0000000000..c522c87232
--- /dev/null
+++ b/server/internal/daemonws/hub_test.go
@@ -0,0 +1,200 @@
+package daemonws
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/multica-ai/multica/server/internal/realtime"
+ "github.com/multica-ai/multica/server/pkg/protocol"
+)
+
+func TestNotifyTaskAvailable(t *testing.T) {
+ M.Reset()
+ defer M.Reset()
+
+ hub := NewHub()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hub.HandleWebSocket(w, r, ClientIdentity{RuntimeIDs: []string{"runtime-1"}})
+ }))
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("Dial: %v", err)
+ }
+ defer conn.Close()
+
+ deadline := time.Now().Add(time.Second)
+ for hub.RuntimeConnectionCount("runtime-1") == 0 {
+ if time.Now().After(deadline) {
+ t.Fatal("runtime connection was not registered")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ hub.NotifyTaskAvailable("runtime-1", "task-1")
+
+ if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
+ t.Fatalf("SetReadDeadline: %v", err)
+ }
+ _, raw, err := conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("ReadMessage: %v", err)
+ }
+
+ var msg protocol.Message
+ if err := json.Unmarshal(raw, &msg); err != nil {
+ t.Fatalf("unmarshal message: %v", err)
+ }
+ if msg.Type != protocol.EventDaemonTaskAvailable {
+ t.Fatalf("message type = %q, want %q", msg.Type, protocol.EventDaemonTaskAvailable)
+ }
+
+ var payload protocol.TaskAvailablePayload
+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if payload.RuntimeID != "runtime-1" || payload.TaskID != "task-1" {
+ t.Fatalf("payload = %+v, want runtime/task IDs", payload)
+ }
+}
+
+func TestRelayNotifierPublishesDaemonRuntimeScope(t *testing.T) {
+ M.Reset()
+ defer M.Reset()
+
+ relay := &recordingRelayPublisher{}
+ notifier := NewRelayNotifier(nil, relay)
+
+ notifier.NotifyTaskAvailable("runtime-1", "task-1")
+
+ if relay.scopeType != realtime.ScopeDaemonRuntime {
+ t.Fatalf("scopeType = %q, want %q", relay.scopeType, realtime.ScopeDaemonRuntime)
+ }
+ if relay.scopeID != "task-1" {
+ t.Fatalf("scopeID = %q, want task_id shard key", relay.scopeID)
+ }
+ if relay.eventID == "" {
+ t.Fatal("expected event id")
+ }
+ if M.WakeupPublishedTotal.Load() != 1 {
+ t.Fatalf("published metric = %d, want 1", M.WakeupPublishedTotal.Load())
+ }
+
+ var msg protocol.Message
+ if err := json.Unmarshal(relay.frame, &msg); err != nil {
+ t.Fatalf("unmarshal frame: %v", err)
+ }
+ if msg.Type != protocol.EventDaemonTaskAvailable {
+ t.Fatalf("message type = %q, want %q", msg.Type, protocol.EventDaemonTaskAvailable)
+ }
+ var payload protocol.TaskAvailablePayload
+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if payload.RuntimeID != "runtime-1" || payload.TaskID != "task-1" {
+ t.Fatalf("payload = %+v, want runtime/task IDs", payload)
+ }
+}
+
+func TestRelayNotifierDedupsLocalRedisLoopback(t *testing.T) {
+ M.Reset()
+ defer M.Reset()
+
+ hub := NewHub()
+ client := attachDaemonTestClient(hub, "runtime-1")
+ relay := &localFirstDaemonRelayPublisher{t: t, client: client}
+ notifier := NewRelayNotifier(hub, relay)
+
+ notifier.NotifyTaskAvailable("runtime-1", "task-1")
+
+ if !relay.called {
+ t.Fatal("expected relay publish to be invoked")
+ }
+ if relay.eventID == "" {
+ t.Fatal("expected event id")
+ }
+ if M.WakeupDeliveredHit.Load() != 1 {
+ t.Fatalf("delivered hit metric = %d, want 1", M.WakeupDeliveredHit.Load())
+ }
+
+ hub.DeliverDaemonRuntime(relay.scopeID, relay.frame, relay.eventID)
+
+ select {
+ case duplicate := <-client.send:
+ t.Fatalf("expected redis loopback to be deduped, got duplicate %s", duplicate)
+ case <-time.After(20 * time.Millisecond):
+ }
+ if M.WakeupDeliveredHit.Load() != 1 {
+ t.Fatalf("delivered hit metric after loopback = %d, want 1", M.WakeupDeliveredHit.Load())
+ }
+ if M.WakeupDeliveredMiss.Load() != 0 {
+ t.Fatalf("delivered miss metric after dedup = %d, want 0", M.WakeupDeliveredMiss.Load())
+ }
+}
+
+func attachDaemonTestClient(hub *Hub, runtimeID string) *client {
+ c := &client{
+ send: make(chan []byte, 2),
+ runtimes: map[string]struct{}{runtimeID: {}},
+ }
+
+ hub.mu.Lock()
+ hub.clients[c] = true
+ hub.byRuntime[runtimeID] = map[*client]bool{c: true}
+ hub.mu.Unlock()
+
+ return c
+}
+
+type recordingRelayPublisher struct {
+ scopeType string
+ scopeID string
+ exclude string
+ frame []byte
+ eventID string
+}
+
+func (r *recordingRelayPublisher) PublishWithID(scopeType, scopeID, exclude string, frame []byte, id string) error {
+ r.scopeType = scopeType
+ r.scopeID = scopeID
+ r.exclude = exclude
+ r.frame = append([]byte(nil), frame...)
+ r.eventID = id
+ return nil
+}
+
+type localFirstDaemonRelayPublisher struct {
+ t *testing.T
+ client *client
+
+ called bool
+ scopeType string
+ scopeID string
+ exclude string
+ frame []byte
+ eventID string
+ localFrame []byte
+}
+
+func (p *localFirstDaemonRelayPublisher) PublishWithID(scopeType, scopeID, exclude string, frame []byte, id string) error {
+ p.called = true
+ p.scopeType = scopeType
+ p.scopeID = scopeID
+ p.exclude = exclude
+ p.frame = append([]byte(nil), frame...)
+ p.eventID = id
+
+ select {
+ case p.localFrame = <-p.client.send:
+ default:
+ p.t.Fatal("expected local fanout to happen before relay publish")
+ }
+ return nil
+}
diff --git a/server/internal/daemonws/metrics.go b/server/internal/daemonws/metrics.go
new file mode 100644
index 0000000000..63bd2ce770
--- /dev/null
+++ b/server/internal/daemonws/metrics.go
@@ -0,0 +1,44 @@
+package daemonws
+
+import "sync/atomic"
+
+type Metrics struct {
+ ConnectsTotal atomic.Int64
+ DisconnectsTotal atomic.Int64
+ ActiveConnections atomic.Int64
+ SlowEvictionsTotal atomic.Int64
+
+ WakeupPublishedTotal atomic.Int64
+ WakeupPublishErrors atomic.Int64
+ WakeupReceivedTotal atomic.Int64
+ WakeupDeliveredHit atomic.Int64
+ WakeupDeliveredMiss atomic.Int64
+}
+
+var M = &Metrics{}
+
+func (m *Metrics) Snapshot() map[string]any {
+ return map[string]any{
+ "connects_total": m.ConnectsTotal.Load(),
+ "disconnects_total": m.DisconnectsTotal.Load(),
+ "active_connections": m.ActiveConnections.Load(),
+ "slow_evictions_total": m.SlowEvictionsTotal.Load(),
+ "wakeup_published_total": m.WakeupPublishedTotal.Load(),
+ "wakeup_publish_errors": m.WakeupPublishErrors.Load(),
+ "wakeup_received_total": m.WakeupReceivedTotal.Load(),
+ "wakeup_delivered_hit_total": m.WakeupDeliveredHit.Load(),
+ "wakeup_delivered_miss_total": m.WakeupDeliveredMiss.Load(),
+ }
+}
+
+func (m *Metrics) Reset() {
+ m.ConnectsTotal.Store(0)
+ m.DisconnectsTotal.Store(0)
+ m.ActiveConnections.Store(0)
+ m.SlowEvictionsTotal.Store(0)
+ m.WakeupPublishedTotal.Store(0)
+ m.WakeupPublishErrors.Store(0)
+ m.WakeupReceivedTotal.Store(0)
+ m.WakeupDeliveredHit.Store(0)
+ m.WakeupDeliveredMiss.Store(0)
+}
diff --git a/server/internal/daemonws/notifier.go b/server/internal/daemonws/notifier.go
new file mode 100644
index 0000000000..c1d1f51cdb
--- /dev/null
+++ b/server/internal/daemonws/notifier.go
@@ -0,0 +1,49 @@
+package daemonws
+
+import (
+ "log/slog"
+
+ "github.com/oklog/ulid/v2"
+
+ "github.com/multica-ai/multica/server/internal/realtime"
+)
+
+// RelayNotifier sends task wakeups to the local daemon hub and, when Redis is
+// configured, publishes the same wakeup through the shared realtime relay so
+// every API node can attempt local delivery.
+type RelayNotifier struct {
+ local *Hub
+ relay realtime.RelayPublisher
+}
+
+func NewRelayNotifier(local *Hub, relay realtime.RelayPublisher) *RelayNotifier {
+ return &RelayNotifier{local: local, relay: relay}
+}
+
+func (n *RelayNotifier) NotifyTaskAvailable(runtimeID, taskID string) {
+ if runtimeID == "" {
+ return
+ }
+ eventID := ulid.Make().String()
+ if n.local != nil {
+ n.local.notifyTaskAvailable(runtimeID, taskID, eventID)
+ }
+ if n.relay == nil {
+ return
+ }
+ frame, err := taskAvailableFrame(runtimeID, taskID)
+ if err != nil {
+ M.WakeupPublishErrors.Add(1)
+ return
+ }
+ shardKey := taskID
+ if shardKey == "" {
+ shardKey = eventID
+ }
+ if err := n.relay.PublishWithID(realtime.ScopeDaemonRuntime, shardKey, "", frame, eventID); err != nil {
+ M.WakeupPublishErrors.Add(1)
+ slog.Warn("daemon websocket wakeup publish failed", "error", err, "runtime_id", runtimeID, "task_id", taskID)
+ return
+ }
+ M.WakeupPublishedTotal.Add(1)
+}
diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go
index 98c4f601de..9950802339 100644
--- a/server/internal/handler/agent.go
+++ b/server/internal/handler/agent.go
@@ -354,9 +354,18 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
req.MaxConcurrentTasks = 6
}
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
- ID: parseUUID(req.RuntimeID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: runtimeUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusBadRequest, "invalid runtime_id")
@@ -369,7 +378,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
// list fails we fall through with isFirstAgent=false rather than
// blocking creation, since the primary DB operation is the insert.
isFirstAgent := false
- if existing, listErr := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID)); listErr == nil {
+ if existing, listErr := h.Queries.ListAgents(r.Context(), wsUUID); listErr == nil {
isFirstAgent = len(existing) == 0
}
@@ -394,7 +403,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
}
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
Name: req.Name,
Description: req.Description,
Instructions: req.Instructions,
@@ -529,7 +538,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
}
params := db.UpdateAgentParams{
- ID: parseUUID(id),
+ ID: agent.ID,
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
@@ -561,8 +570,12 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
params.McpConfig = append([]byte(nil), rawMcpConfig...)
}
if req.RuntimeID != nil {
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, *req.RuntimeID, "runtime_id")
+ if !ok {
+ return
+ }
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
- ID: parseUUID(*req.RuntimeID),
+ ID: runtimeUUID,
WorkspaceID: agent.WorkspaceID,
})
if err != nil {
@@ -595,7 +608,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
// mcp_config: null in the request means explicitly clear the field.
// COALESCE in UpdateAgent cannot set a column to NULL, so we use a dedicated query.
if shouldClearMcpConfig {
- agent, err = h.Queries.ClearAgentMcpConfig(r.Context(), parseUUID(id))
+ agent, err = h.Queries.ClearAgentMcpConfig(r.Context(), agent.ID)
if err != nil {
slog.Warn("clear agent mcp_config failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
writeError(w, http.StatusInternalServerError, "failed to clear mcp_config: "+err.Error())
@@ -627,7 +640,7 @@ func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) {
userID := requestUserID(r)
archived, err := h.Queries.ArchiveAgent(r.Context(), db.ArchiveAgentParams{
- ID: parseUUID(id),
+ ID: agent.ID,
ArchivedBy: parseUUID(userID),
})
if err != nil {
@@ -640,7 +653,7 @@ func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) {
// rows here — the agent:archived event below already triggers a full
// active-tasks invalidation on every connected client, so per-task
// task:cancelled events would be redundant noise.
- if _, err := h.Queries.CancelAgentTasksByAgent(r.Context(), parseUUID(id)); err != nil {
+ if _, err := h.Queries.CancelAgentTasksByAgent(r.Context(), agent.ID); err != nil {
slog.Warn("cancel agent tasks on archive failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
}
@@ -666,7 +679,7 @@ func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) {
return
}
- restored, err := h.Queries.RestoreAgent(r.Context(), parseUUID(id))
+ restored, err := h.Queries.RestoreAgent(r.Context(), agent.ID)
if err != nil {
slog.Warn("restore agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
writeError(w, http.StatusInternalServerError, "failed to restore agent")
@@ -722,11 +735,12 @@ func (h *Handler) CancelAgentTasks(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
- if _, ok := h.loadAgentForUser(w, r, id); !ok {
+ agent, ok := h.loadAgentForUser(w, r, id)
+ if !ok {
return
}
- tasks, err := h.Queries.ListAgentTasks(r.Context(), parseUUID(id))
+ tasks, err := h.Queries.ListAgentTasks(r.Context(), agent.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent tasks")
return
diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go
index 4345b46fe7..d2847ccb34 100644
--- a/server/internal/handler/auth.go
+++ b/server/internal/handler/auth.go
@@ -2,11 +2,11 @@ package handler
import (
"context"
- "errors"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
"encoding/json"
+ "errors"
"fmt"
"io"
"log/slog"
@@ -36,6 +36,8 @@ func (e SignupError) Error() string {
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
+const devVerificationCodeEnv = "MULTICA_DEV_VERIFICATION_CODE"
+
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -92,6 +94,35 @@ func generateCode() (string, error) {
return fmt.Sprintf("%06d", n), nil
}
+func isDevVerificationCode(code string) bool {
+ if isProductionEnv() {
+ return false
+ }
+
+ devCode := strings.TrimSpace(os.Getenv(devVerificationCodeEnv))
+ if !isSixDigitCode(devCode) {
+ return false
+ }
+
+ return subtle.ConstantTimeCompare([]byte(code), []byte(devCode)) == 1
+}
+
+func isProductionEnv() bool {
+ return strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production")
+}
+
+func isSixDigitCode(code string) bool {
+ if len(code) != 6 {
+ return false
+ }
+ for _, ch := range code {
+ if ch < '0' || ch > '9' {
+ return false
+ }
+ }
+ return true
+}
+
func (h *Handler) issueJWT(user db.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": uuidToString(user.ID),
@@ -311,8 +342,8 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
return
}
- isMasterCode := code == "888888" && os.Getenv("APP_ENV") != "production"
- if !isMasterCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
+ isDevCode := isDevVerificationCode(code)
+ if !isDevCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
_ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID)
writeError(w, http.StatusBadRequest, "invalid or expired code")
return
diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go
index 79891d9894..514c927443 100644
--- a/server/internal/handler/autopilot.go
+++ b/server/internal/handler/autopilot.go
@@ -194,12 +194,8 @@ func (h *Handler) GetAutopilot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
- autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(id),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "autopilot not found")
+ autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
+ if !ok {
return
}
@@ -221,6 +217,27 @@ func (h *Handler) GetAutopilot(w http.ResponseWriter, r *http.Request) {
})
}
+func (h *Handler) loadAutopilotInWorkspace(w http.ResponseWriter, r *http.Request, autopilotID, workspaceID string) (db.Autopilot, bool) {
+ autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id")
+ if !ok {
+ return db.Autopilot{}, false
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return db.Autopilot{}, false
+ }
+
+ autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
+ ID: autopilotUUID,
+ WorkspaceID: wsUUID,
+ })
+ if err != nil {
+ writeError(w, http.StatusNotFound, "autopilot not found")
+ return db.Autopilot{}, false
+ }
+ return autopilot, true
+}
+
func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
var req CreateAutopilotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -250,10 +267,19 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
return
}
+ assigneeUUID, ok := parseUUIDOrBadRequest(w, req.AssigneeID, "assignee_id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
// Validate assignee is an agent in the workspace.
_, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
- ID: parseUUID(req.AssigneeID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: assigneeUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
@@ -261,9 +287,9 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
}
autopilot, err := h.Queries.CreateAutopilot(r.Context(), db.CreateAutopilotParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
Title: req.Title,
- AssigneeID: parseUUID(req.AssigneeID),
+ AssigneeID: assigneeUUID,
Status: "active",
ExecutionMode: req.ExecutionMode,
CreatedByType: "member",
@@ -285,12 +311,8 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
- prev, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(id),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "autopilot not found")
+ prev, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
+ if !ok {
return
}
@@ -335,14 +357,18 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["assignee_id"]; ok {
if req.AssigneeID != nil {
+ assigneeUUID, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
+ if !ok {
+ return
+ }
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
- ID: parseUUID(*req.AssigneeID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: assigneeUUID,
+ WorkspaceID: prev.WorkspaceID,
}); err != nil {
writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
return
}
- params.AssigneeID = parseUUID(*req.AssigneeID)
+ params.AssigneeID = assigneeUUID
}
}
@@ -361,9 +387,18 @@ func (h *Handler) DeleteAutopilot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "autopilot id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(id),
- WorkspaceID: parseUUID(workspaceID),
+ ID: idUUID,
+ WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "autopilot not found")
return
@@ -374,12 +409,12 @@ func (h *Handler) DeleteAutopilot(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.Queries.DeleteAutopilot(r.Context(), parseUUID(id)); err != nil {
+ if err := h.Queries.DeleteAutopilot(r.Context(), idUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete autopilot")
return
}
- h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": id})
+ h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": uuidToString(idUUID)})
w.WriteHeader(http.StatusNoContent)
}
@@ -389,12 +424,8 @@ func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request)
autopilotID := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
- ap, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(autopilotID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "autopilot not found")
+ ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
+ if !ok {
return
}
@@ -454,7 +485,7 @@ func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request)
resp := triggerToResponse(trigger)
userID, _ := requireUserID(w, r)
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
- "autopilot_id": autopilotID,
+ "autopilot_id": uuidToString(ap.ID),
"trigger": resp,
})
writeJSON(w, http.StatusCreated, resp)
@@ -465,17 +496,18 @@ func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request)
triggerID := chi.URLParam(r, "triggerId")
workspaceID := h.resolveWorkspaceID(r)
- // Verify autopilot belongs to workspace.
- if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(autopilotID),
- WorkspaceID: parseUUID(workspaceID),
- }); err != nil {
- writeError(w, http.StatusNotFound, "autopilot not found")
+ ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
+ if !ok {
return
}
- prev, err := h.Queries.GetAutopilotTrigger(r.Context(), parseUUID(triggerID))
- if err != nil || uuidToString(prev.AutopilotID) != autopilotID {
+ triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
+ if !ok {
+ return
+ }
+
+ prev, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
+ if err != nil || uuidToString(prev.AutopilotID) != uuidToString(ap.ID) {
writeError(w, http.StatusNotFound, "trigger not found")
return
}
@@ -542,7 +574,7 @@ func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request)
resp := triggerToResponse(trigger)
userID, _ := requireUserID(w, r)
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
- "autopilot_id": autopilotID,
+ "autopilot_id": uuidToString(ap.ID),
"trigger": resp,
})
writeJSON(w, http.StatusOK, resp)
@@ -553,16 +585,29 @@ func (h *Handler) DeleteAutopilotTrigger(w http.ResponseWriter, r *http.Request)
triggerID := chi.URLParam(r, "triggerId")
workspaceID := h.resolveWorkspaceID(r)
+ autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id")
+ if !ok {
+ return
+ }
+ triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(autopilotID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: autopilotUUID,
+ WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "autopilot not found")
return
}
- trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), parseUUID(triggerID))
- if err != nil || uuidToString(trigger.AutopilotID) != autopilotID {
+ trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID)
+ if err != nil || uuidToString(trigger.AutopilotID) != uuidToString(autopilotUUID) {
writeError(w, http.StatusNotFound, "trigger not found")
return
}
@@ -572,14 +617,14 @@ func (h *Handler) DeleteAutopilotTrigger(w http.ResponseWriter, r *http.Request)
return
}
- if err := h.Queries.DeleteAutopilotTrigger(r.Context(), parseUUID(triggerID)); err != nil {
+ if err := h.Queries.DeleteAutopilotTrigger(r.Context(), triggerUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete trigger")
return
}
h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{
- "autopilot_id": autopilotID,
- "trigger_id": triggerID,
+ "autopilot_id": uuidToString(autopilotUUID),
+ "trigger_id": uuidToString(triggerUUID),
})
w.WriteHeader(http.StatusNoContent)
}
@@ -590,11 +635,8 @@ func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) {
autopilotID := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
- if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(autopilotID),
- WorkspaceID: parseUUID(workspaceID),
- }); err != nil {
- writeError(w, http.StatusNotFound, "autopilot not found")
+ autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID)
+ if !ok {
return
}
@@ -615,7 +657,7 @@ func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) {
}
runs, err := h.Queries.ListAutopilotRuns(r.Context(), db.ListAutopilotRunsParams{
- AutopilotID: parseUUID(autopilotID),
+ AutopilotID: autopilot.ID,
Limit: limit,
Offset: offset,
})
@@ -637,12 +679,8 @@ func (h *Handler) TriggerAutopilot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
- autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{
- ID: parseUUID(id),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "autopilot not found")
+ autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID)
+ if !ok {
return
}
if autopilot.Status != "active" {
diff --git a/server/internal/handler/chat.go b/server/internal/handler/chat.go
index 1e880508d7..292f968af5 100644
--- a/server/internal/handler/chat.go
+++ b/server/internal/handler/chat.go
@@ -35,11 +35,19 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "agent_id is required")
return
}
+ agentID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
+ if !ok {
+ return
+ }
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
// Verify agent exists in workspace.
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
- ID: parseUUID(req.AgentID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: agentID,
+ WorkspaceID: workspaceUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
@@ -51,8 +59,8 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) {
}
session, err := h.Queries.CreateChatSession(r.Context(), db.CreateChatSessionParams{
- WorkspaceID: parseUUID(workspaceID),
- AgentID: parseUUID(req.AgentID),
+ WorkspaceID: workspaceUUID,
+ AgentID: agentID,
CreatorID: parseUUID(userID),
Title: req.Title,
})
@@ -126,6 +134,30 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
+func (h *Handler) loadChatSessionForUser(w http.ResponseWriter, r *http.Request, userID, workspaceID, sessionID string) (db.ChatSession, bool) {
+ sessionUUID, ok := parseUUIDOrBadRequest(w, sessionID, "chat session id")
+ if !ok {
+ return db.ChatSession{}, false
+ }
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return db.ChatSession{}, false
+ }
+ session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
+ ID: sessionUUID,
+ WorkspaceID: workspaceUUID,
+ })
+ if err != nil {
+ writeError(w, http.StatusNotFound, "chat session not found")
+ return db.ChatSession{}, false
+ }
+ if uuidToString(session.CreatorID) != userID {
+ writeError(w, http.StatusForbidden, "not your chat session")
+ return db.ChatSession{}, false
+ }
+ return session, true
+}
+
func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -134,16 +166,8 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
- session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
- ID: parseUUID(sessionID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "chat session not found")
- return
- }
- if uuidToString(session.CreatorID) != userID {
- writeError(w, http.StatusForbidden, "not your chat session")
+ session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
+ if !ok {
return
}
@@ -158,20 +182,12 @@ func (h *Handler) ArchiveChatSession(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
- session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
- ID: parseUUID(sessionID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "chat session not found")
- return
- }
- if uuidToString(session.CreatorID) != userID {
- writeError(w, http.StatusForbidden, "not your chat session")
+ session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
+ if !ok {
return
}
- if err := h.Queries.ArchiveChatSession(r.Context(), parseUUID(sessionID)); err != nil {
+ if err := h.Queries.ArchiveChatSession(r.Context(), session.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive chat session")
return
}
@@ -211,22 +227,14 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
}
// Load chat session.
- session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
- ID: parseUUID(sessionID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "chat session not found")
+ session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
+ if !ok {
return
}
if session.Status != "active" {
writeError(w, http.StatusBadRequest, "chat session is archived")
return
}
- if uuidToString(session.CreatorID) != userID {
- writeError(w, http.StatusForbidden, "not your chat session")
- return
- }
// Create the user message first so the daemon can always find it.
msg, err := h.Queries.CreateChatMessage(r.Context(), db.CreateChatMessageParams{
@@ -252,8 +260,9 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) {
}
// Broadcast the user message.
- h.publishChat(protocol.EventChatMessage, workspaceID, "member", userID, sessionID, protocol.ChatMessagePayload{
- ChatSessionID: sessionID,
+ resolvedSessionID := uuidToString(session.ID)
+ h.publishChat(protocol.EventChatMessage, workspaceID, "member", userID, resolvedSessionID, protocol.ChatMessagePayload{
+ ChatSessionID: resolvedSessionID,
MessageID: uuidToString(msg.ID),
Role: "user",
Content: req.Content,
@@ -275,20 +284,12 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
- session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
- ID: parseUUID(sessionID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "chat session not found")
- return
- }
- if uuidToString(session.CreatorID) != userID {
- writeError(w, http.StatusForbidden, "not your chat session")
+ session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
+ if !ok {
return
}
- messages, err := h.Queries.ListChatMessages(r.Context(), parseUUID(sessionID))
+ messages, err := h.Queries.ListChatMessages(r.Context(), session.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list chat messages")
return
@@ -319,26 +320,19 @@ func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
- session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
- ID: parseUUID(sessionID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "chat session not found")
- return
- }
- if uuidToString(session.CreatorID) != userID {
- writeError(w, http.StatusForbidden, "not your chat session")
+ session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
+ if !ok {
return
}
- if err := h.Queries.MarkChatSessionRead(r.Context(), parseUUID(sessionID)); err != nil {
+ if err := h.Queries.MarkChatSessionRead(r.Context(), session.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark session read")
return
}
- h.publishChat(protocol.EventChatSessionRead, workspaceID, "member", userID, sessionID, protocol.ChatSessionReadPayload{
- ChatSessionID: sessionID,
+ resolvedSessionID := uuidToString(session.ID)
+ h.publishChat(protocol.EventChatSessionRead, workspaceID, "member", userID, resolvedSessionID, protocol.ChatSessionReadPayload{
+ ChatSessionID: resolvedSessionID,
})
w.WriteHeader(http.StatusNoContent)
@@ -396,20 +390,12 @@ func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) {
workspaceID := ctxWorkspaceID(r.Context())
sessionID := chi.URLParam(r, "sessionId")
- session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{
- ID: parseUUID(sessionID),
- WorkspaceID: parseUUID(workspaceID),
- })
- if err != nil {
- writeError(w, http.StatusNotFound, "chat session not found")
- return
- }
- if uuidToString(session.CreatorID) != userID {
- writeError(w, http.StatusForbidden, "not your chat session")
+ session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID)
+ if !ok {
return
}
- task, err := h.Queries.GetPendingChatTask(r.Context(), parseUUID(sessionID))
+ task, err := h.Queries.GetPendingChatTask(r.Context(), session.ID)
if err != nil {
// No in-flight task — return an empty object, not an error.
writeJSON(w, http.StatusOK, PendingChatTaskResponse{})
@@ -435,8 +421,12 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
}
workspaceID := ctxWorkspaceID(r.Context())
taskID := chi.URLParam(r, "taskId")
+ taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task id")
+ if !ok {
+ return
+ }
- task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID))
+ task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
@@ -468,7 +458,7 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
return
}
- cancelled, err := h.TaskService.CancelTask(r.Context(), parseUUID(taskID))
+ cancelled, err := h.TaskService.CancelTask(r.Context(), taskUUID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go
index a8b9dda216..5eac1a4365 100644
--- a/server/internal/handler/comment.go
+++ b/server/internal/handler/comment.go
@@ -203,15 +203,25 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
var parentID pgtype.UUID
var parentComment *db.Comment
if req.ParentID != nil {
- parentID = parseUUID(*req.ParentID)
+ var parsed pgtype.UUID
+ parsed, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
+ if !ok {
+ return
+ }
+ parentID = parsed
parent, err := h.Queries.GetComment(r.Context(), parentID)
- if err != nil || uuidToString(parent.IssueID) != issueID {
+ if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "invalid parent comment")
return
}
parentComment = &parent
}
+ attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
+ if !ok {
+ return
+ }
+
// Determine author identity: agent (via X-Agent-ID header) or member.
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
@@ -227,12 +237,15 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// tasks (no TriggerCommentID) are also unaffected.
if authorType == "agent" {
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
- task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskIDHeader))
- if err == nil && task.TriggerCommentID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
- if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
- writeError(w, http.StatusConflict,
- "parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
- return
+ taskUUID, parseErr := util.ParseUUID(taskIDHeader)
+ if parseErr == nil {
+ task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
+ if err == nil && task.TriggerCommentID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
+ if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
+ writeError(w, http.StatusConflict,
+ "parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
+ return
+ }
}
}
}
@@ -263,8 +276,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
}
// Link uploaded attachments to this comment.
- if len(req.AttachmentIDs) > 0 {
- h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs)
+ if len(attachmentIDs) > 0 {
+ h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, attachmentIDs)
}
// Fetch linked attachments so the response includes them.
@@ -411,7 +424,12 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
// but only when the reply contains no mentions at all (a plain follow-up).
// If the reply explicitly @mentions anyone (agents or members), the user
// is making a deliberate choice about who to involve; don't auto-inherit.
- if parentComment != nil && len(mentions) == 0 {
+ //
+ // CRITICAL: agent-authored replies must NOT inherit parent mentions.
+ // Otherwise an agent posting "No reply needed" in a thread whose root
+ // mentioned another agent would re-trigger that agent, creating a loop.
+ // Explicit @mentions in the agent's own comment still work for delegation.
+ if parentComment != nil && len(mentions) == 0 && authorType != "agent" {
mentions = util.ParseMentions(parentComment.Content)
}
for _, m := range mentions {
@@ -461,12 +479,20 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
+ if !ok {
+ return
+ }
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
- ID: parseUUID(commentId),
- WorkspaceID: parseUUID(workspaceID),
+ ID: commentUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
@@ -501,7 +527,7 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
- ID: parseUUID(commentId),
+ ID: commentUUID,
Content: req.Content,
})
if err != nil {
@@ -528,11 +554,20 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
return
}
+ commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
+ if !ok {
+ return
+ }
+
// Load comment scoped to current workspace.
workspaceID := h.resolveWorkspaceID(r)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
- ID: parseUUID(commentId),
- WorkspaceID: parseUUID(workspaceID),
+ ID: commentUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
@@ -553,9 +588,17 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
}
// Collect attachment URLs before CASCADE delete removes them.
- attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), parseUUID(commentId))
+ attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), comment.ID)
- if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
+ // Cancel any active tasks triggered by this comment so the agent does not
+ // run with the now-deleted content already embedded in its prompt. Must
+ // run before DeleteComment because the FK ON DELETE SET NULL would
+ // otherwise nullify trigger_comment_id and orphan those tasks in queued.
+ if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), comment.ID); err != nil {
+ slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
+ }
+
+ if err := h.Queries.DeleteComment(r.Context(), comment.ID); err != nil {
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
writeError(w, http.StatusInternalServerError, "failed to delete comment")
return
@@ -564,7 +607,7 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
h.deleteS3Objects(r.Context(), attachmentURLs)
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{
- "comment_id": commentId,
+ "comment_id": uuidToString(comment.ID),
"issue_id": uuidToString(comment.IssueID),
})
w.WriteHeader(http.StatusNoContent)
diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go
index d525890138..b92f121b34 100644
--- a/server/internal/handler/daemon.go
+++ b/server/internal/handler/daemon.go
@@ -52,7 +52,11 @@ func (h *Handler) requireDaemonWorkspaceAccess(w http.ResponseWriter, r *http.Re
// requireDaemonRuntimeAccess looks up a runtime and verifies the caller owns its workspace.
func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (db.AgentRuntime, bool) {
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return db.AgentRuntime{}, false
+ }
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return db.AgentRuntime{}, false
@@ -65,7 +69,11 @@ func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Requ
// requireDaemonTaskAccess looks up a task and verifies the caller owns its workspace.
func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, bool) {
- task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID))
+ taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id")
+ if !ok {
+ return db.AgentTaskQueue{}, false
+ }
+ task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return db.AgentTaskQueue{}, false
@@ -210,6 +218,11 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "at least one runtime is required")
return
}
+ wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+ req.WorkspaceID = uuidToString(wsUUID)
// Verify workspace access and resolve owner.
// Daemon tokens (mdt_) prove workspace access directly; OwnerID will be zero
@@ -230,7 +243,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
ownerID = member.UserID
}
- ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID))
+ ws, err := h.Queries.GetWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
@@ -266,7 +279,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
})
row, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
DaemonID: strToText(req.DaemonID),
Name: name,
RuntimeMode: "local",
@@ -444,13 +457,17 @@ func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "runtime_ids is required")
return
}
+ runtimeUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.RuntimeIDs, "runtime_ids")
+ if !ok {
+ return
+ }
// Track affected workspaces for WS notifications.
affectedWorkspaces := make(map[string]bool)
- for _, rid := range req.RuntimeIDs {
+ for i, rid := range req.RuntimeIDs {
// Look up the runtime and verify ownership.
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(rid))
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUIDs[i])
if err != nil {
slog.Warn("deregister: runtime not found", "runtime_id", rid, "error", err)
continue
@@ -462,7 +479,7 @@ func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) {
continue
}
- if err := h.Queries.SetAgentRuntimeOffline(r.Context(), parseUUID(rid)); err != nil {
+ if err := h.Queries.SetAgentRuntimeOffline(r.Context(), rt.ID); err != nil {
slog.Warn("deregister: failed to set offline", "runtime_id", rid, "error", err)
continue
}
@@ -524,13 +541,14 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
runtimeID = req.RuntimeID
// Verify the caller owns this runtime's workspace.
- if _, ok := h.requireDaemonRuntimeAccess(w, r, req.RuntimeID); !ok {
+ rt, ok := h.requireDaemonRuntimeAccess(w, r, req.RuntimeID)
+ if !ok {
return
}
authMs = time.Since(start).Milliseconds()
updateStart := time.Now()
- _, err := h.Queries.UpdateAgentRuntimeHeartbeat(r.Context(), parseUUID(req.RuntimeID))
+ _, err := h.Queries.UpdateAgentRuntimeHeartbeat(r.Context(), rt.ID)
updateMs = time.Since(updateStart).Milliseconds()
if err != nil {
outcome = "error_update"
@@ -1449,7 +1467,11 @@ func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) {
// read issue metadata from workspace B via UUID enumeration.
func (h *Handler) GetIssueGCCheck(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "issueId")
- issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID))
+ issueUUID, ok := parseUUIDOrBadRequest(w, issueID, "issue_id")
+ if !ok {
+ return
+ }
+ issue, err := h.Queries.GetIssue(r.Context(), issueUUID)
if err != nil {
writeError(w, http.StatusNotFound, "issue not found")
return
diff --git a/server/internal/handler/daemon_ws.go b/server/internal/handler/daemon_ws.go
new file mode 100644
index 0000000000..869e1a13d0
--- /dev/null
+++ b/server/internal/handler/daemon_ws.go
@@ -0,0 +1,66 @@
+package handler
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/multica-ai/multica/server/internal/daemonws"
+ "github.com/multica-ai/multica/server/internal/middleware"
+)
+
+func (h *Handler) DaemonWebSocket(w http.ResponseWriter, r *http.Request) {
+ if h.DaemonHub == nil {
+ writeError(w, http.StatusServiceUnavailable, "daemon websocket unavailable")
+ return
+ }
+
+ runtimeIDs := parseRuntimeIDs(r)
+ if len(runtimeIDs) == 0 {
+ writeError(w, http.StatusBadRequest, "runtime_ids required")
+ return
+ }
+
+ for _, runtimeID := range runtimeIDs {
+ rt, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID)
+ if !ok {
+ return
+ }
+ if daemonID := middleware.DaemonIDFromContext(r.Context()); daemonID != "" && rt.DaemonID.Valid && rt.DaemonID.String != daemonID {
+ writeError(w, http.StatusNotFound, "runtime not found")
+ return
+ }
+ }
+
+ h.DaemonHub.HandleWebSocket(w, r, daemonws.ClientIdentity{
+ DaemonID: middleware.DaemonIDFromContext(r.Context()),
+ UserID: requestUserID(r),
+ WorkspaceID: middleware.DaemonWorkspaceIDFromContext(r.Context()),
+ RuntimeIDs: runtimeIDs,
+ ClientVersion: r.Header.Get("X-Client-Version"),
+ })
+}
+
+func parseRuntimeIDs(r *http.Request) []string {
+ seen := map[string]struct{}{}
+ var out []string
+ add := func(raw string) {
+ for _, part := range strings.Split(raw, ",") {
+ id := strings.TrimSpace(part)
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ }
+ for _, raw := range r.URL.Query()["runtime_id"] {
+ add(raw)
+ }
+ for _, raw := range r.URL.Query()["runtime_ids"] {
+ add(raw)
+ }
+ return out
+}
diff --git a/server/internal/handler/feedback.go b/server/internal/handler/feedback.go
index f427abcc76..45c8b9dd95 100644
--- a/server/internal/handler/feedback.go
+++ b/server/internal/handler/feedback.go
@@ -7,6 +7,7 @@ import (
"regexp"
"strings"
+ "github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/middleware"
@@ -93,9 +94,13 @@ func (h *Handler) CreateFeedback(w http.ResponseWriter, r *http.Request) {
metaBytes = []byte("{}")
}
- var workspaceID = parseUUID("")
+ var workspaceID pgtype.UUID
if req.WorkspaceID != nil && *req.WorkspaceID != "" {
- workspaceID = parseUUID(*req.WorkspaceID)
+ ws, ok := parseUUIDOrBadRequest(w, *req.WorkspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+ workspaceID = ws
}
fb, err := h.Queries.CreateFeedback(r.Context(), db.CreateFeedbackParams{
diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go
index 5267b544ab..06c4ad2f71 100644
--- a/server/internal/handler/file.go
+++ b/server/internal/handler/file.go
@@ -188,8 +188,12 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
}
if issueID := r.FormValue("issue_id"); issueID != "" {
+ issueUUID, ok := parseUUIDOrBadRequest(w, issueID, "issue_id")
+ if !ok {
+ return
+ }
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
- ID: parseUUID(issueID),
+ ID: issueUUID,
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
@@ -199,7 +203,11 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
params.IssueID = issue.ID
}
if commentID := r.FormValue("comment_id"); commentID != "" {
- comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentID))
+ commentUUID, ok := parseUUIDOrBadRequest(w, commentID, "comment_id")
+ if !ok {
+ return
+ }
+ comment, err := h.Queries.GetComment(r.Context(), commentUUID)
if err != nil || uuidToString(comment.WorkspaceID) != workspaceID {
writeError(w, http.StatusForbidden, "invalid comment_id")
return
@@ -285,9 +293,18 @@ func (h *Handler) GetAttachmentByID(w http.ResponseWriter, r *http.Request) {
return
}
+ attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
- ID: parseUUID(attachmentID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: attUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
@@ -314,9 +331,18 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
return
}
+ attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
- ID: parseUUID(attachmentID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: attUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
@@ -353,15 +379,11 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
// linkAttachmentsByIssueIDs links the given attachment IDs to an issue.
// Only updates attachments that have no issue_id yet.
-func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []string) {
- uuids := make([]pgtype.UUID, len(ids))
- for i, id := range ids {
- uuids[i] = parseUUID(id)
- }
+func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []pgtype.UUID) {
if err := h.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
IssueID: issueID,
WorkspaceID: workspaceID,
- Column3: uuids,
+ Column3: ids,
}); err != nil {
slog.Error("failed to link attachments to issue", "error", err)
}
@@ -369,15 +391,11 @@ func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, worksp
// linkAttachmentsByIDs links the given attachment IDs to a comment.
// Only updates attachments that belong to the same issue and have no comment_id yet.
-func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []string) {
- uuids := make([]pgtype.UUID, len(ids))
- for i, id := range ids {
- uuids[i] = parseUUID(id)
- }
+func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []pgtype.UUID) {
if err := h.Queries.LinkAttachmentsToComment(ctx, db.LinkAttachmentsToCommentParams{
CommentID: commentID,
IssueID: issueID,
- Column3: uuids,
+ Column3: ids,
}); err != nil {
slog.Error("failed to link attachments to comment", "error", err)
}
diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go
index a687850b8e..039e3cce26 100644
--- a/server/internal/handler/handler.go
+++ b/server/internal/handler/handler.go
@@ -15,6 +15,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/auth"
+ "github.com/multica-ai/multica/server/internal/daemonws"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/realtime"
@@ -53,6 +54,7 @@ type Handler struct {
DB dbExecutor
TxStarter txStarter
Hub *realtime.Hub
+ DaemonHub *daemonws.Hub
Bus *events.Bus
TaskService *service.TaskService
AutopilotService *service.AutopilotService
@@ -67,7 +69,7 @@ type Handler struct {
cfg Config
}
-func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config) *Handler {
+func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler {
var executor dbExecutor
if candidate, ok := txStarter.(dbExecutor); ok {
executor = candidate
@@ -77,12 +79,18 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
analyticsClient = analytics.NoopClient{}
}
- taskSvc := service.NewTaskService(queries, txStarter, hub, bus)
+ var daemonHub *daemonws.Hub
+ if len(daemonHubs) > 0 {
+ daemonHub = daemonHubs[0]
+ }
+
+ taskSvc := service.NewTaskService(queries, txStarter, hub, bus, daemonHub)
return &Handler{
Queries: queries,
DB: executor,
TxStarter: txStarter,
Hub: hub,
+ DaemonHub: daemonHub,
Bus: bus,
TaskService: taskSvc,
AutopilotService: service.NewAutopilotService(queries, txStarter, bus, taskSvc),
@@ -108,8 +116,19 @@ func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
-// Thin wrappers around util functions (preserve existing handler code unchanged).
-func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
+// Thin wrappers around util functions.
+//
+// parseUUID is intentionally the panicking variant: any handler call site
+// reachable here is expected to feed a UUID that is either (a) a sqlc round-trip
+// of a DB-sourced value, or (b) a raw request input that has already been
+// validated upstream. A panic here means an unguarded user-input string slipped
+// in — that is a real bug we want surfaced loudly (chi's middleware.Recoverer
+// converts it to a 500) instead of silently corrupting data via a zero UUID.
+//
+// For unvalidated user input at request boundaries, use parseUUIDOrBadRequest
+// (writes 400) — never feed raw chi.URLParam / request-body strings into
+// parseUUID directly when the call writes to the database.
+func parseUUID(s string) pgtype.UUID { return util.MustParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
@@ -118,6 +137,35 @@ func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToStr
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
+// parseUUIDOrBadRequest validates a UUID string sourced from user input
+// (URL params, request body, headers). On invalid input it writes a 400
+// response and returns ok=false; callers must return immediately.
+//
+// Use this anywhere a malformed UUID would otherwise reach a write query
+// (DELETE / UPDATE) — the silent zero-UUID behavior of the old ParseUUID
+// caused real silent-data-loss bugs (#1661).
+func parseUUIDOrBadRequest(w http.ResponseWriter, s, fieldName string) (pgtype.UUID, bool) {
+ u, err := util.ParseUUID(s)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid "+fieldName)
+ return pgtype.UUID{}, false
+ }
+ return u, true
+}
+
+func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName string) ([]pgtype.UUID, bool) {
+ uuids := make([]pgtype.UUID, len(ids))
+ for i, id := range ids {
+ u, err := util.ParseUUID(id)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid "+fieldName)
+ return nil, false
+ }
+ uuids[i] = u
+ }
+ return uuids, true
+}
+
// publish sends a domain event through the event bus.
func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) {
h.Bus.Publish(events.Event{
@@ -179,8 +227,13 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act
return "member", userID
}
+ agentUUID, err := util.ParseUUID(agentID)
+ if err != nil {
+ slog.Debug("resolveActor: X-Agent-ID is not a valid UUID, falling back to member", "agent_id", agentID)
+ return "member", userID
+ }
// Validate the agent exists in the target workspace.
- agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID))
+ agent, err := h.Queries.GetAgent(r.Context(), agentUUID)
if err != nil || uuidToString(agent.WorkspaceID) != workspaceID {
slog.Debug("resolveActor: X-Agent-ID rejected, agent not found or workspace mismatch", "agent_id", agentID, "workspace_id", workspaceID)
return "member", userID
@@ -188,7 +241,12 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act
// When X-Task-ID is provided, cross-check that the task belongs to this agent.
if taskID := r.Header.Get("X-Task-ID"); taskID != "" {
- task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID))
+ taskUUID, err := util.ParseUUID(taskID)
+ if err != nil {
+ slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID)
+ return "member", userID
+ }
+ task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || uuidToString(task.AgentID) != agentID {
slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID)
return "member", userID
@@ -265,9 +323,17 @@ func countOwners(members []db.Member) int {
}
func (h *Handler) getWorkspaceMember(ctx context.Context, userID, workspaceID string) (db.Member, error) {
+ userUUID, err := util.ParseUUID(userID)
+ if err != nil {
+ return db.Member{}, err
+ }
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ return db.Member{}, err
+ }
return h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
- UserID: parseUUID(userID),
- WorkspaceID: parseUUID(workspaceID),
+ UserID: userUUID,
+ WorkspaceID: wsUUID,
})
}
@@ -311,9 +377,17 @@ func (h *Handler) isWorkspaceEntity(ctx context.Context, userType, userID, works
_, err := h.getWorkspaceMember(ctx, userID, workspaceID)
return err == nil
case "agent":
- _, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
- ID: parseUUID(userID),
- WorkspaceID: parseUUID(workspaceID),
+ userUUID, err := util.ParseUUID(userID)
+ if err != nil {
+ return false
+ }
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ return false
+ }
+ _, err = h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
+ ID: userUUID,
+ WorkspaceID: wsUUID,
})
return err == nil
default:
@@ -332,14 +406,28 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue
return db.Issue{}, false
}
- // Try identifier format first (e.g., "JIA-42").
+ // Try identifier format first (e.g., "JIA-42"). resolveIssueByIdentifier
+ // silently returns false for non-identifier strings, falling through to
+ // the UUID path below.
if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, workspaceID); ok {
return issue, true
}
+ issueUUID, err := util.ParseUUID(issueID)
+ if err != nil {
+ // Not a valid UUID and didn't match identifier format → 404 (consistent
+ // with previous silent-zero behavior, which would also have produced 404).
+ writeError(w, http.StatusNotFound, "issue not found")
+ return db.Issue{}, false
+ }
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid workspace_id")
+ return db.Issue{}, false
+ }
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
- ID: parseUUID(issueID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: issueUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "issue not found")
@@ -357,8 +445,12 @@ func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID
if workspaceID == "" {
return db.Issue{}, false
}
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ return db.Issue{}, false
+ }
issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
Number: parts.number,
})
if err != nil {
@@ -422,9 +514,18 @@ func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agent
return db.Agent{}, false
}
+ agentUUID, ok := parseUUIDOrBadRequest(w, agentID, "agent id")
+ if !ok {
+ return db.Agent{}, false
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return db.Agent{}, false
+ }
+
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
- ID: parseUUID(agentID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: agentUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
@@ -445,9 +546,18 @@ func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, i
return db.InboxItem{}, false
}
+ itemUUID, ok := parseUUIDOrBadRequest(w, itemID, "inbox item id")
+ if !ok {
+ return db.InboxItem{}, false
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return db.InboxItem{}, false
+ }
+
item, err := h.Queries.GetInboxItemInWorkspace(r.Context(), db.GetInboxItemInWorkspaceParams{
- ID: parseUUID(itemID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: itemUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "inbox item not found")
diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go
index 0c6205ab55..967f7229fc 100644
--- a/server/internal/handler/handler_test.go
+++ b/server/internal/handler/handler_test.go
@@ -10,6 +10,7 @@ import (
"os"
"strings"
"testing"
+ "time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -18,6 +19,7 @@ import (
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
+ "github.com/multica-ai/multica/server/pkg/protocol"
)
var testHandler *Handler
@@ -330,6 +332,92 @@ func TestIssueCRUD(t *testing.T) {
}
}
+// TestDeleteIssueByIdentifier guards against #1661 — DELETE /api/issues/{id}
+// must actually delete the row when the path segment is a human-readable
+// identifier ("HAN-42") rather than a UUID. Before the PR #1680 + MUL-1410
+// refactor, parseUUID(rawString) silently produced a zero UUID, the SQL
+// DELETE matched nothing, and the handler still returned 204.
+//
+// Also asserts the issue:deleted WS event payload carries the resolved UUID,
+// not the raw identifier — frontend caches key by UUID and would otherwise
+// leave stale entries on other clients after an identifier-path delete.
+func TestDeleteIssueByIdentifier(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Issue to delete by identifier",
+ "status": "todo",
+ "priority": "medium",
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var created IssueResponse
+ json.NewDecoder(w.Body).Decode(&created)
+ if created.Identifier == "" {
+ t.Fatalf("CreateIssue: expected identifier to be populated, got empty")
+ }
+
+ // Capture the issue:deleted event payload via the bus.
+ gotPayload := make(chan map[string]any, 1)
+ testHandler.Bus.Subscribe(protocol.EventIssueDeleted, func(e events.Event) {
+ if payload, ok := e.Payload.(map[string]any); ok {
+ select {
+ case gotPayload <- payload:
+ default:
+ }
+ }
+ })
+
+ // Delete using the human-readable identifier (e.g. "HAN-1") rather than the UUID.
+ w = httptest.NewRecorder()
+ req = newRequest("DELETE", "/api/issues/"+created.Identifier, nil)
+ req = withURLParam(req, "id", created.Identifier)
+ testHandler.DeleteIssue(w, req)
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("DeleteIssue by identifier: expected 204, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Verify the row is actually gone — the silent-data-loss bug would have
+ // returned 204 here too, but the row would still exist.
+ var count int
+ if err := testPool.QueryRow(context.Background(),
+ `SELECT COUNT(*) FROM issue WHERE id = $1`, created.ID,
+ ).Scan(&count); err != nil {
+ t.Fatalf("count query: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("DeleteIssue by identifier returned 204 but row still exists (count=%d) — silent-data-loss regression", count)
+ }
+
+ // Event payload must carry the resolved UUID, not the identifier string.
+ select {
+ case payload := <-gotPayload:
+ issueID, _ := payload["issue_id"].(string)
+ if issueID != created.ID {
+ t.Fatalf("issue:deleted event payload issue_id = %q; want resolved UUID %q (must not leak identifier %q)", issueID, created.ID, created.Identifier)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("did not receive issue:deleted event within timeout")
+ }
+}
+
+// TestDeleteIssueRejectsInvalidUUID verifies that a path segment that is
+// neither a valid UUID nor a valid identifier returns 404 (not 204) — the
+// handler must never silently succeed on malformed input.
+func TestDeleteIssueRejectsInvalidUUID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/issues/not-a-uuid-or-identifier", nil)
+ req = withURLParam(req, "id", "not-a-uuid-or-identifier")
+ testHandler.DeleteIssue(w, req)
+ if w.Code == http.StatusNoContent {
+ t.Fatalf("DeleteIssue with invalid id: must not return 204; got %d", w.Code)
+ }
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("DeleteIssue with invalid id: expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
// TestCreateIssueDefaultStatusIsTodo verifies that issues created without an
// explicit status default to "todo" so the daemon picks them up immediately.
// Before this fix the default was "backlog", which daemons ignore.
@@ -641,6 +729,31 @@ func TestCreateIssueRejectsMalformedAssigneeID(t *testing.T) {
}
}
+func TestCreateIssueRejectsMalformedAttachmentIDBeforeWrite(t *testing.T) {
+ var before int
+ if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&before); err != nil {
+ t.Fatalf("count issues before: %v", err)
+ }
+
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Malformed attachment issue",
+ "attachment_ids": []string{"not-a-uuid"},
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreateIssue: expected 400 for malformed attachment_ids, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var after int
+ if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&after); err != nil {
+ t.Fatalf("count issues after: %v", err)
+ }
+ if after != before {
+ t.Fatalf("CreateIssue: malformed attachment_ids should not create issue, count before=%d after=%d", before, after)
+ }
+}
+
// TestUpdateIssueRejectsMalformedAssigneeID is the equivalent for the update
// path, where the same parseUUID-shaped gap existed on a previously-unassigned
// issue.
@@ -789,6 +902,283 @@ func TestCommentCRUD(t *testing.T) {
testHandler.DeleteIssue(w, req)
}
+func TestCreateCommentRejectsMalformedParentID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Comment malformed parent issue",
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var issue IssueResponse
+ json.NewDecoder(w.Body).Decode(&issue)
+
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/issues/"+issue.ID+"/comments", map[string]any{
+ "content": "bad parent",
+ "parent_id": "not-a-uuid",
+ })
+ req = withURLParam(req, "id", issue.ID)
+ testHandler.CreateComment(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreateComment: expected 400 for malformed parent_id, got %d: %s", w.Code, w.Body.String())
+ }
+
+ w = httptest.NewRecorder()
+ req = newRequest("DELETE", "/api/issues/"+issue.ID, nil)
+ req = withURLParam(req, "id", issue.ID)
+ testHandler.DeleteIssue(w, req)
+}
+
+func TestGetChatSessionRejectsMalformedSessionID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("GET", "/api/chat/sessions/not-a-uuid", nil)
+ req = withURLParam(req, "sessionId", "not-a-uuid")
+ testHandler.GetChatSession(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("GetChatSession: expected 400 for malformed sessionId, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestCreateAutopilotRejectsMalformedAssigneeID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/autopilots", map[string]any{
+ "title": "Malformed assignee autopilot",
+ "assignee_id": "not-a-uuid",
+ "execution_mode": "run_only",
+ })
+ testHandler.CreateAutopilot(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreateAutopilot: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUpdateAutopilotRejectsMalformedID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("PUT", "/api/autopilots/not-a-uuid", map[string]any{
+ "title": "Malformed autopilot id",
+ })
+ req = withURLParam(req, "id", "not-a-uuid")
+ testHandler.UpdateAutopilot(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("UpdateAutopilot: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUpdateAgentRejectsMalformedAgentID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("PUT", "/api/agents/not-a-uuid", map[string]any{
+ "name": "Malformed agent id",
+ })
+ req = withURLParam(req, "id", "not-a-uuid")
+ testHandler.UpdateAgent(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("UpdateAgent: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestCreateAgentRejectsMalformedRuntimeID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/agents", map[string]any{
+ "name": "Malformed runtime agent",
+ "runtime_id": "not-a-uuid",
+ })
+ testHandler.CreateAgent(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUpdateAgentRejectsMalformedRuntimeID(t *testing.T) {
+ agentID := createHandlerTestAgent(t, "Handler Malformed Runtime Update", nil)
+
+ w := httptest.NewRecorder()
+ req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
+ "runtime_id": "not-a-uuid",
+ })
+ req = withURLParam(req, "id", agentID)
+ testHandler.UpdateAgent(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("UpdateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestCreatePinRejectsMalformedItemID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/pins", map[string]any{
+ "item_type": "issue",
+ "item_id": "not-a-uuid",
+ })
+ testHandler.CreatePin(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreatePin: expected 400 for malformed item_id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUpdateWorkspaceRejectsMalformedID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("PUT", "/api/workspaces/not-a-uuid", map[string]any{
+ "name": "Malformed workspace id",
+ })
+ req = withURLParam(req, "id", "not-a-uuid")
+ testHandler.UpdateWorkspace(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("UpdateWorkspace: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUpdateMemberRejectsMalformedMemberID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("PATCH", "/api/workspaces/"+testWorkspaceID+"/members/not-a-uuid", map[string]any{
+ "role": "member",
+ })
+ req = withURLParams(req, "id", testWorkspaceID, "memberId", "not-a-uuid")
+ testHandler.UpdateMember(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("UpdateMember: expected 400 for malformed memberId, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestRevokeInvitationRejectsMalformedInvitationID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/invitations/not-a-uuid", nil)
+ req = withURLParams(req, "id", testWorkspaceID, "invitationId", "not-a-uuid")
+ testHandler.RevokeInvitation(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("RevokeInvitation: expected 400 for malformed invitationId, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGetMyInvitationRejectsMalformedID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("GET", "/api/invitations/not-a-uuid", nil)
+ req = withURLParam(req, "id", "not-a-uuid")
+ testHandler.GetMyInvitation(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("GetMyInvitation: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestAddReactionRejectsMalformedCommentID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/comments/not-a-uuid/reactions", map[string]any{
+ "emoji": "thumbs_up",
+ })
+ req = withURLParam(req, "commentId", "not-a-uuid")
+ testHandler.AddReaction(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("AddReaction: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestUpdateCommentRejectsMalformedCommentID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("PUT", "/api/comments/not-a-uuid", map[string]any{
+ "content": "updated",
+ })
+ req = withURLParam(req, "commentId", "not-a-uuid")
+ testHandler.UpdateComment(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("UpdateComment: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestMarkInboxReadRejectsMalformedItemID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/inbox/not-a-uuid/read", nil)
+ req = withURLParam(req, "id", "not-a-uuid")
+ testHandler.MarkInboxRead(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("MarkInboxRead: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestRevokePersonalAccessTokenRejectsMalformedID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/tokens/not-a-uuid", nil)
+ req = withURLParam(req, "id", "not-a-uuid")
+ testHandler.RevokePersonalAccessToken(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("RevokePersonalAccessToken: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestRequestBodyUUIDFieldsRejectMalformed(t *testing.T) {
+ tests := []struct {
+ name string
+ req *http.Request
+ handle func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ name: "daemon register workspace_id",
+ req: newRequest("POST", "/api/daemon/register", map[string]any{
+ "workspace_id": "not-a-uuid",
+ "daemon_id": "daemon-malformed-workspace",
+ "runtimes": []map[string]any{
+ {"name": "codex", "type": "codex", "status": "online"},
+ },
+ }),
+ handle: testHandler.DaemonRegister,
+ },
+ {
+ name: "import starter content workspace_id",
+ req: newRequest("POST", "/api/onboarding/starter-content/import", map[string]any{
+ "workspace_id": "not-a-uuid",
+ "project": map[string]any{
+ "title": "Getting Started",
+ },
+ }),
+ handle: testHandler.ImportStarterContent,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ tt.handle(w, tt.req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("%s: expected 400 for malformed body UUID, got %d: %s", tt.name, w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+func TestDaemonDeregisterRejectsMalformedRuntimeID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/daemon/deregister", map[string]any{
+ "runtime_ids": []string{"not-a-uuid"},
+ })
+ testHandler.DaemonDeregister(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("DaemonDeregister: expected 400 for malformed runtime_ids, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGetIssueGCCheckRejectsMalformedIssueID(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("GET", "/api/daemon/issues/not-a-uuid/gc-check", nil)
+ req = withURLParam(req, "issueId", "not-a-uuid")
+ testHandler.GetIssueGCCheck(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("GetIssueGCCheck: expected 400 for malformed issueId, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestSetAgentSkillsRejectsMalformedSkillID(t *testing.T) {
+ agentID := createHandlerTestAgent(t, "Handler Malformed Skill Assignment", nil)
+
+ w := httptest.NewRecorder()
+ req := newRequest("PUT", "/api/agents/"+agentID+"/skills", map[string]any{
+ "skill_ids": []string{"not-a-uuid"},
+ })
+ req = withURLParam(req, "id", agentID)
+ testHandler.SetAgentSkills(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("SetAgentSkills: expected 400 for malformed skill_ids, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
func TestAgentCRUD(t *testing.T) {
// List agents
w := httptest.NewRecorder()
@@ -1038,7 +1428,7 @@ func TestSendCodeDbError(t *testing.T) {
// We can't easily mock the DB here without changing architecture,
// but we can simulate a DB error by closing the pool temporarily or
// using a cancelled context if the query respects it.
-
+
// Create a handler with a "broken" queries object is hard because it's a struct.
// Instead, let's use a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
@@ -1053,13 +1443,13 @@ func TestSendCodeDbError(t *testing.T) {
req = req.WithContext(ctx)
testHandler.SendCode(w, req)
-
+
// If the DB query respects the cancelled context, it should return an error.
// pgx usually returns context.Canceled which is not what isNotFound checks for.
if w.Code != http.StatusInternalServerError {
t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String())
}
-
+
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "failed to lookup user" {
@@ -1153,7 +1543,94 @@ func TestVerifyCode(t *testing.T) {
}
}
+func createVerificationCodeForTest(t *testing.T, email, code string) {
+ t.Helper()
+
+ _, err := testPool.Exec(context.Background(), `
+ INSERT INTO verification_code (email, code, expires_at)
+ VALUES ($1, $2, now() + interval '10 minutes')
+ `, email, code)
+ if err != nil {
+ t.Fatalf("create verification code: %v", err)
+ }
+}
+
+func TestVerifyCodeRejectsDevCodeUnlessExplicitlyConfigured(t *testing.T) {
+ t.Setenv(devVerificationCodeEnv, "")
+ t.Setenv("APP_ENV", "")
+
+ const email = "dev-code-disabled-test@multica.ai"
+ ctx := context.Background()
+
+ t.Cleanup(func() {
+ testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
+ })
+
+ createVerificationCodeForTest(t, email, "123456")
+
+ w := httptest.NewRecorder()
+ var buf bytes.Buffer
+ json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
+ req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
+ req.Header.Set("Content-Type", "application/json")
+ testHandler.VerifyCode(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("VerifyCode (disabled dev code): expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestVerifyCodeAcceptsConfiguredDevCodeOutsideProduction(t *testing.T) {
+ t.Setenv(devVerificationCodeEnv, "888888")
+ t.Setenv("APP_ENV", "development")
+
+ const email = "dev-code-enabled-test@multica.ai"
+ ctx := context.Background()
+
+ t.Cleanup(func() {
+ testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
+ testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
+ })
+
+ createVerificationCodeForTest(t, email, "123456")
+
+ w := httptest.NewRecorder()
+ var buf bytes.Buffer
+ json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
+ req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
+ req.Header.Set("Content-Type", "application/json")
+ testHandler.VerifyCode(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("VerifyCode (enabled dev code): expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestVerifyCodeRejectsConfiguredDevCodeInProduction(t *testing.T) {
+ t.Setenv(devVerificationCodeEnv, "888888")
+ t.Setenv("APP_ENV", "production")
+
+ const email = "dev-code-production-test@multica.ai"
+ ctx := context.Background()
+
+ t.Cleanup(func() {
+ testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
+ })
+
+ createVerificationCodeForTest(t, email, "123456")
+
+ w := httptest.NewRecorder()
+ var buf bytes.Buffer
+ json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
+ req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
+ req.Header.Set("Content-Type", "application/json")
+ testHandler.VerifyCode(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("VerifyCode (production dev code): expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
func TestVerifyCodeWrongCode(t *testing.T) {
+ t.Setenv(devVerificationCodeEnv, "")
+
const email = "wrong-code-test@multica.ai"
ctx := context.Background()
@@ -1182,6 +1659,8 @@ func TestVerifyCodeWrongCode(t *testing.T) {
}
func TestVerifyCodeBruteForceProtection(t *testing.T) {
+ t.Setenv(devVerificationCodeEnv, "")
+
const email = "bruteforce-test@multica.ai"
ctx := context.Background()
@@ -1519,3 +1998,187 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
t.Fatalf("DaemonRegister: expected workspace not found error, got %s", w.Body.String())
}
}
+
+// TestAgentReplyDoesNotInheritParentMentions verifies that agent-authored
+// replies do NOT inherit parent-comment mentions, preventing agent-to-agent
+// re-trigger loops (e.g. "No reply needed" chains). Member-authored replies
+// still inherit parent mentions as expected.
+func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+ ctx := context.Background()
+
+ // Create two agents.
+ agentA := createHandlerTestAgent(t, "Loop Agent A", nil)
+ agentB := createHandlerTestAgent(t, "Loop Agent B", nil)
+
+ // Create an unassigned issue so on_comment doesn't fire.
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Agent mention inheritance test",
+ "status": "todo",
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var issue IssueResponse
+ json.NewDecoder(w.Body).Decode(&issue)
+ issueID := issue.ID
+
+ t.Cleanup(func() {
+ testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
+ testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
+ testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
+ })
+
+ // Helper: count queued tasks for a given agent on this issue.
+ countTasks := func(agentID string) int {
+ var n int
+ err := testPool.QueryRow(ctx,
+ `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
+ issueID, agentID,
+ ).Scan(&n)
+ if err != nil {
+ t.Fatalf("failed to count tasks: %v", err)
+ }
+ return n
+ }
+
+ // Helper: cancel all tasks for an agent on this issue.
+ cancelTasks := func(agentID string) {
+ _, err := testPool.Exec(ctx,
+ `UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
+ issueID, agentID,
+ )
+ if err != nil {
+ t.Fatalf("failed to cancel tasks: %v", err)
+ }
+ }
+
+ postComment := func(issueID string, body map[string]any, headers map[string]string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
+ r = withURLParam(r, "id", issueID)
+ for k, v := range headers {
+ r.Header.Set(k, v)
+ }
+ testHandler.CreateComment(w, r)
+ return w
+ }
+
+ // 1. Member posts top-level comment mentioning Agent B.
+ mentionB := fmt.Sprintf("[@Agent B](mention://agent/%s) please review", agentB)
+ w = postComment(issueID, map[string]any{"content": mentionB}, nil)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("member mention comment: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var parentComment CommentResponse
+ json.NewDecoder(w.Body).Decode(&parentComment)
+ if countTasks(agentB) != 1 {
+ t.Fatalf("expected 1 task for Agent B after member mention, got %d", countTasks(agentB))
+ }
+
+ // 2. Cancel Agent B's task so it's free to be re-triggered.
+ cancelTasks(agentB)
+ if countTasks(agentB) != 0 {
+ t.Fatalf("expected 0 tasks for Agent B after cancel, got %d", countTasks(agentB))
+ }
+
+ // 3. Agent A posts a reply in the same thread with NO mentions.
+ // With the fix, this must NOT inherit the parent mention of Agent B.
+ w = postComment(issueID, map[string]any{
+ "content": "No reply needed — just an acknowledgment.",
+ "parent_id": parentComment.ID,
+ }, map[string]string{"X-Agent-ID": agentA})
+ if w.Code != http.StatusCreated {
+ t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ if countTasks(agentB) != 0 {
+ t.Fatalf("expected 0 tasks for Agent B after agent reply (no parent inheritance), got %d", countTasks(agentB))
+ }
+
+ // 4. Cancel any stray tasks.
+ cancelTasks(agentB)
+
+ // 5. Member posts a reply in the same thread with NO mentions.
+ // This SHOULD inherit the parent mention and re-trigger Agent B.
+ w = postComment(issueID, map[string]any{
+ "content": "Thanks for the review.",
+ "parent_id": parentComment.ID,
+ }, nil)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("member reply: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ if countTasks(agentB) != 1 {
+ t.Fatalf("expected 1 task for Agent B after member reply (parent inheritance allowed), got %d", countTasks(agentB))
+ }
+}
+
+// TestAgentExplicitMentionStillTriggers documents the boundary the structural
+// fix preserves: suppressing implicit parent-mention inheritance for agent
+// authors does NOT block deliberate handoffs. An agent that explicitly
+// @mentions another agent in its own comment content still enqueues a task
+// for that mentioned agent.
+func TestAgentExplicitMentionStillTriggers(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+ ctx := context.Background()
+
+ agentA := createHandlerTestAgent(t, "Handoff Agent A", nil)
+ agentB := createHandlerTestAgent(t, "Handoff Agent B", nil)
+
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Agent explicit handoff test",
+ "status": "todo",
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var issue IssueResponse
+ json.NewDecoder(w.Body).Decode(&issue)
+ issueID := issue.ID
+
+ t.Cleanup(func() {
+ testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
+ testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
+ testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
+ })
+
+ countTasks := func(agentID string) int {
+ var n int
+ err := testPool.QueryRow(ctx,
+ `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
+ issueID, agentID,
+ ).Scan(&n)
+ if err != nil {
+ t.Fatalf("failed to count tasks: %v", err)
+ }
+ return n
+ }
+
+ // Agent A posts a top-level comment that explicitly @mentions Agent B —
+ // a deliberate handoff. This must enqueue a task for Agent B, and must
+ // not enqueue a self-trigger for Agent A.
+ explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
+ w = httptest.NewRecorder()
+ r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
+ "content": explicitMention,
+ })
+ r = withURLParam(r, "id", issueID)
+ r.Header.Set("X-Agent-ID", agentA)
+ testHandler.CreateComment(w, r)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ if got := countTasks(agentB); got != 1 {
+ t.Fatalf("expected 1 task for Agent B after explicit mention by Agent A, got %d", got)
+ }
+ if got := countTasks(agentA); got != 0 {
+ t.Fatalf("expected 0 tasks for Agent A (no self-trigger on own mention), got %d", got)
+ }
+}
diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go
index 1888ca940c..554202372f 100644
--- a/server/internal/handler/inbox.go
+++ b/server/internal/handler/inbox.go
@@ -91,9 +91,13 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := ctxWorkspaceID(r.Context())
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
RecipientType: "member",
RecipientID: parseUUID(userID),
})
@@ -112,10 +116,11 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
- if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
+ prev, ok := h.loadInboxItemForUser(w, r, id)
+ if !ok {
return
}
- item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
+ item, err := h.Queries.MarkInboxRead(r.Context(), prev.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark read")
return
@@ -134,10 +139,11 @@ func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
- if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
+ prev, ok := h.loadInboxItemForUser(w, r, id)
+ if !ok {
return
}
- item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
+ item, err := h.Queries.ArchiveInboxItem(r.Context(), prev.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive")
return
@@ -171,9 +177,13 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := ctxWorkspaceID(r.Context())
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
count, err := h.Queries.CountUnreadInbox(r.Context(), db.CountUnreadInboxParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
RecipientType: "member",
RecipientID: parseUUID(userID),
})
@@ -191,9 +201,13 @@ func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := ctxWorkspaceID(r.Context())
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
count, err := h.Queries.MarkAllInboxRead(r.Context(), db.MarkAllInboxReadParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
@@ -216,9 +230,13 @@ func (h *Handler) ArchiveAllInbox(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := ctxWorkspaceID(r.Context())
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
count, err := h.Queries.ArchiveAllInbox(r.Context(), db.ArchiveAllInboxParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
@@ -241,9 +259,13 @@ func (h *Handler) ArchiveAllReadInbox(w http.ResponseWriter, r *http.Request) {
return
}
workspaceID := ctxWorkspaceID(r.Context())
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
count, err := h.Queries.ArchiveAllReadInbox(r.Context(), db.ArchiveAllReadInboxParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
@@ -266,9 +288,13 @@ func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request)
return
}
workspaceID := ctxWorkspaceID(r.Context())
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
diff --git a/server/internal/handler/invitation.go b/server/internal/handler/invitation.go
index a524e2ef54..7c6bc789ed 100644
--- a/server/internal/handler/invitation.go
+++ b/server/internal/handler/invitation.go
@@ -87,7 +87,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
if err == nil {
_, memberErr := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: existingUser.ID,
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: requester.WorkspaceID,
})
if memberErr == nil {
writeError(w, http.StatusConflict, "user is already a member")
@@ -97,7 +97,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
// Check if there is already a pending invitation.
_, err = h.Queries.GetPendingInvitationByEmail(r.Context(), db.GetPendingInvitationByEmailParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: requester.WorkspaceID,
InviteeEmail: email,
})
if err == nil {
@@ -112,7 +112,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
}
inv, err := h.Queries.CreateInvitation(r.Context(), db.CreateInvitationParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: requester.WorkspaceID,
InviterID: requester.UserID,
InviteeEmail: email,
InviteeUserID: inviteeUserID,
@@ -136,15 +136,15 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
userID := requestUserID(r)
eventPayload := map[string]any{"invitation": resp}
var workspaceName string
- if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID)); err == nil {
+ if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil {
workspaceName = ws.Name
eventPayload["workspace_name"] = ws.Name
}
- h.publish(protocol.EventInvitationCreated, workspaceID, "member", userID, eventPayload)
+ h.publish(protocol.EventInvitationCreated, uuidToString(requester.WorkspaceID), "member", userID, eventPayload)
h.Analytics.Capture(analytics.TeamInviteSent(
uuidToString(requester.UserID),
- workspaceID,
+ uuidToString(requester.WorkspaceID),
email,
"email",
))
@@ -173,8 +173,12 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
- rows, err := h.Queries.ListPendingInvitationsByWorkspace(r.Context(), parseUUID(workspaceID))
+ rows, err := h.Queries.ListPendingInvitationsByWorkspace(r.Context(), workspaceUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list invitations")
return
@@ -209,9 +213,17 @@ func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Reques
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
invitationID := chi.URLParam(r, "invitationId")
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+ invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
+ if !ok {
+ return
+ }
- inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
- if err != nil || uuidToString(inv.WorkspaceID) != workspaceID || inv.Status != "pending" {
+ inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
+ if err != nil || uuidToString(inv.WorkspaceID) != uuidToString(workspaceUUID) || inv.Status != "pending" {
writeError(w, http.StatusNotFound, "invitation not found")
return
}
@@ -224,8 +236,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
slog.Info("invitation revoked", "invitation_id", invitationID, "workspace_id", workspaceID)
userID := requestUserID(r)
- h.publish(protocol.EventInvitationRevoked, workspaceID, "member", userID, map[string]any{
- "invitation_id": invitationID,
+ h.publish(protocol.EventInvitationRevoked, uuidToString(workspaceUUID), "member", userID, map[string]any{
+ "invitation_id": uuidToString(inv.ID),
"invitee_email": inv.InviteeEmail,
"invitee_user_id": uuidToPtr(inv.InviteeUserID),
})
@@ -245,7 +257,11 @@ func (h *Handler) GetMyInvitation(w http.ResponseWriter, r *http.Request) {
}
invitationID := chi.URLParam(r, "id")
- inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
+ invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
+ if !ok {
+ return
+ }
+ inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
@@ -336,7 +352,11 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
}
invitationID := chi.URLParam(r, "id")
- inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
+ invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
+ if !ok {
+ return
+ }
+ inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
@@ -413,7 +433,7 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
// Notify the workspace about the acceptance.
h.publish(protocol.EventInvitationAccepted, wsID, "member", userID, map[string]any{
- "invitation_id": invitationID,
+ "invitation_id": uuidToString(accepted.ID),
"member": memberResp,
})
@@ -445,7 +465,11 @@ func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) {
}
invitationID := chi.URLParam(r, "id")
- inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID))
+ invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id")
+ if !ok {
+ return
+ }
+ inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID)
if err != nil {
writeError(w, http.StatusNotFound, "invitation not found")
return
@@ -477,7 +501,7 @@ func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) {
wsID := uuidToString(declined.WorkspaceID)
h.publish(protocol.EventInvitationDeclined, wsID, "member", userID, map[string]any{
- "invitation_id": invitationID,
+ "invitation_id": uuidToString(declined.ID),
"invitee_email": declined.InviteeEmail,
})
diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go
index cee39fddb8..10eee7d73d 100644
--- a/server/internal/handler/issue.go
+++ b/server/internal/handler/issue.go
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
+ "github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
@@ -42,6 +43,13 @@ type IssueResponse struct {
UpdatedAt string `json:"updated_at"`
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
+ // Labels are bulk-attached by list/detail endpoints so the client can render
+ // chips without an N+1 round-trip per row. Pointer + omitempty so paths that
+ // don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
+ // WS broadcast) emit no `labels` field at all — the client merge then
+ // preserves whatever labels are already in cache. nil pointer = "field
+ // absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
+ Labels *[]LabelResponse `json:"labels,omitempty"`
}
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
@@ -93,6 +101,37 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons
}
}
+// labelsByIssue bulk-loads labels for the given issue IDs and returns a map
+// keyed by issue UUID string. On error or empty input, returns an empty map —
+// label rendering is non-critical and we'd rather serve issues without labels
+// than fail the whole list call.
+func (h *Handler) labelsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) map[string][]LabelResponse {
+ out := map[string][]LabelResponse{}
+ if len(issueIDs) == 0 {
+ return out
+ }
+ rows, err := h.Queries.ListLabelsForIssues(ctx, db.ListLabelsForIssuesParams{
+ IssueIds: issueIDs,
+ WorkspaceID: wsUUID,
+ })
+ if err != nil {
+ slog.Warn("ListLabelsForIssues failed", "error", err)
+ return out
+ }
+ for _, r := range rows {
+ issueID := uuidToString(r.IssueID)
+ out[issueID] = append(out[issueID], LabelResponse{
+ ID: uuidToString(r.ID),
+ WorkspaceID: uuidToString(r.WorkspaceID),
+ Name: r.Name,
+ Color: r.Color,
+ CreatedAt: timestampToString(r.CreatedAt),
+ UpdatedAt: timestampToString(r.UpdatedAt),
+ })
+ }
+ return out
+}
+
func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse {
identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number))
return IssueResponse{
@@ -472,7 +511,10 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) {
includeClosed := r.URL.Query().Get("include_closed") == "true"
- wsUUID := parseUUID(workspaceID)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
terms := splitSearchTerms(q)
queryNum, hasNum := parseQueryNumber(q)
@@ -559,32 +601,53 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceID := h.resolveWorkspaceID(r)
- wsUUID := parseUUID(workspaceID)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
- // Parse optional filter params
+ // Parse optional filter params. Malformed UUIDs in filters return 400 —
+ // silently coercing them to a zero UUID would mask a client bug and let
+ // the query return an empty result set (or worse, match a NULL row).
var priorityFilter pgtype.Text
if p := r.URL.Query().Get("priority"); p != "" {
priorityFilter = pgtype.Text{String: p, Valid: true}
}
var assigneeFilter pgtype.UUID
if a := r.URL.Query().Get("assignee_id"); a != "" {
- assigneeFilter = parseUUID(a)
+ id, ok := parseUUIDOrBadRequest(w, a, "assignee_id")
+ if !ok {
+ return
+ }
+ assigneeFilter = id
}
var assigneeIdsFilter []pgtype.UUID
if ids := r.URL.Query().Get("assignee_ids"); ids != "" {
for _, raw := range strings.Split(ids, ",") {
if s := strings.TrimSpace(raw); s != "" {
- assigneeIdsFilter = append(assigneeIdsFilter, parseUUID(s))
+ id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids")
+ if !ok {
+ return
+ }
+ assigneeIdsFilter = append(assigneeIdsFilter, id)
}
}
}
var creatorFilter pgtype.UUID
if c := r.URL.Query().Get("creator_id"); c != "" {
- creatorFilter = parseUUID(c)
+ id, ok := parseUUIDOrBadRequest(w, c, "creator_id")
+ if !ok {
+ return
+ }
+ creatorFilter = id
}
var projectFilter pgtype.UUID
if p := r.URL.Query().Get("project_id"); p != "" {
- projectFilter = parseUUID(p)
+ id, ok := parseUUIDOrBadRequest(w, p, "project_id")
+ if !ok {
+ return
+ }
+ projectFilter = id
}
// open_only=true returns all non-done/cancelled issues (no limit).
@@ -603,9 +666,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
prefix := h.getIssuePrefix(ctx, wsUUID)
+ ids := make([]pgtype.UUID, len(issues))
+ for i, issue := range issues {
+ ids[i] = issue.ID
+ }
+ labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = openIssueRowToResponse(issue, prefix)
+ labels := labelsMap[resp[i].ID]
+ if labels == nil {
+ labels = []LabelResponse{}
+ }
+ resp[i].Labels = &labels
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -664,9 +737,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
}
prefix := h.getIssuePrefix(ctx, wsUUID)
+ ids := make([]pgtype.UUID, len(issues))
+ for i, issue := range issues {
+ ids[i] = issue.ID
+ }
+ labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueListRowToResponse(issue, prefix)
+ labels := labelsMap[resp[i].ID]
+ if labels == nil {
+ labels = []LabelResponse{}
+ }
+ resp[i].Labels = &labels
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -683,6 +766,11 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
+ detailLabels := h.labelsByIssue(r.Context(), issue.WorkspaceID, []pgtype.UUID{issue.ID})[uuidToString(issue.ID)]
+ if detailLabels == nil {
+ detailLabels = []LabelResponse{}
+ }
+ resp.Labels = &detailLabels
// Fetch issue reactions.
reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID)
@@ -731,7 +819,10 @@ func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
wsID := h.resolveWorkspaceID(r)
- wsUUID := parseUUID(wsID)
+ wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace_id")
+ if !ok {
+ return
+ }
rows, err := h.Queries.ChildIssueProgress(r.Context(), wsUUID)
if err != nil {
@@ -783,6 +874,10 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
}
workspaceID := h.resolveWorkspaceID(r)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
// Get creator from context (set by auth middleware)
creatorID, ok := requireUserID(w, r)
@@ -805,14 +900,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
}
if req.AssigneeID != nil {
- assigneeID = parseUUID(*req.AssigneeID)
- // parseUUID silently returns an invalid pgtype.UUID for malformed input.
- // Reject explicitly so the validator below cannot mistake "type unset
- // + id unparseable" for "no assignee" and accept the request.
- if !assigneeID.Valid {
- writeError(w, http.StatusBadRequest, "assignee_id is not a valid UUID")
+ id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
+ if !ok {
return
}
+ assigneeID = id
}
if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, assigneeType, assigneeID); status != 0 {
@@ -823,14 +915,22 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
var parentIssueID pgtype.UUID
var projectID pgtype.UUID
if req.ProjectID != nil {
- projectID = parseUUID(*req.ProjectID)
+ id, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id")
+ if !ok {
+ return
+ }
+ projectID = id
}
if req.ParentIssueID != nil {
- parentIssueID = parseUUID(*req.ParentIssueID)
+ id, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id")
+ if !ok {
+ return
+ }
+ parentIssueID = id
// Validate parent exists in the same workspace.
parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: parentIssueID,
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
})
if err != nil || !parent.ID.Valid {
writeError(w, http.StatusBadRequest, "parent issue not found in this workspace")
@@ -841,6 +941,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
}
}
+ attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
+ if !ok {
+ return
+ }
+
var dueDate pgtype.Timestamptz
if req.DueDate != nil && *req.DueDate != "" {
t, err := time.Parse(time.RFC3339, *req.DueDate)
@@ -861,7 +966,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
- issueNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(workspaceID))
+ issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
if err != nil {
slog.Warn("increment issue counter failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to create issue")
@@ -872,7 +977,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID)
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
Title: req.Title,
Description: ptrToText(req.Description),
Status: status,
@@ -899,15 +1004,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
}
// Link any pre-uploaded attachments to this issue.
- if len(req.AttachmentIDs) > 0 {
- h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, req.AttachmentIDs)
+ if len(attachmentIDs) > 0 {
+ h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, attachmentIDs)
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
// Fetch linked attachments so they appear in the response.
- if len(req.AttachmentIDs) > 0 {
+ if len(attachmentIDs) > 0 {
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
@@ -1008,11 +1113,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["assignee_id"]; ok {
if req.AssigneeID != nil {
- params.AssigneeID = parseUUID(*req.AssigneeID)
- if !params.AssigneeID.Valid {
- writeError(w, http.StatusBadRequest, "assignee_id is not a valid UUID")
+ id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
+ if !ok {
return
}
+ params.AssigneeID = id
} else {
params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign
}
@@ -1031,9 +1136,14 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["parent_issue_id"]; ok {
if req.ParentIssueID != nil {
- newParentID := parseUUID(*req.ParentIssueID)
- // Cannot set self as parent.
- if uuidToString(newParentID) == id {
+ newParentID, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id")
+ if !ok {
+ return
+ }
+ // Cannot set self as parent. Compare against prevIssue.ID (the
+ // resolved entity), not the raw URL string — `id` may be an
+ // identifier like "MUL-7".
+ if newParentID == prevIssue.ID {
writeError(w, http.StatusBadRequest, "an issue cannot be its own parent")
return
}
@@ -1052,7 +1162,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
if err != nil || !ancestor.ParentIssueID.Valid {
break
}
- if uuidToString(ancestor.ParentIssueID) == id {
+ if ancestor.ParentIssueID == prevIssue.ID {
writeError(w, http.StatusBadRequest, "circular parent relationship detected")
return
}
@@ -1065,7 +1175,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["project_id"]; ok {
if req.ProjectID != nil {
- params.ProjectID = parseUUID(*req.ProjectID)
+ projectUUID, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id")
+ if !ok {
+ return
+ }
+ params.ProjectID = projectUUID
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
@@ -1172,11 +1286,15 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor
if assigneeType.Valid != assigneeID.Valid {
return http.StatusBadRequest, "assignee_type and assignee_id must be provided together"
}
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ return http.StatusBadRequest, "invalid workspace_id"
+ }
switch assigneeType.String {
case "member":
if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: assigneeID,
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
}); err != nil {
return http.StatusBadRequest, "assignee_id does not refer to a member of this workspace"
}
@@ -1184,7 +1302,7 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor
case "agent":
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
ID: assigneeID,
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
})
if err != nil {
return http.StatusBadRequest, "assignee_id does not refer to an agent of this workspace"
@@ -1278,8 +1396,12 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
h.deleteS3Objects(r.Context(), attachmentURLs)
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
- h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": id})
- slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", uuidToString(issue.WorkspaceID))...)
+ // Always emit the resolved UUID — frontend caches key by UUID, so an
+ // identifier-style payload ("MUL-123") would leave stale entries on
+ // other clients after an identifier-path delete.
+ resolvedID := uuidToString(issue.ID)
+ h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": resolvedID})
+ slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", resolvedID, "workspace_id", uuidToString(issue.WorkspaceID))...)
w.WriteHeader(http.StatusNoContent)
}
@@ -1324,11 +1446,19 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
workspaceID := h.resolveWorkspaceID(r)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
updated := 0
for _, issueID := range req.IssueIDs {
+ issueUUID, err := util.ParseUUID(issueID)
+ if err != nil {
+ continue
+ }
prevIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
- ID: parseUUID(issueID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: issueUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
continue
@@ -1367,10 +1497,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawUpdates["assignee_id"]; ok {
if req.Updates.AssigneeID != nil {
- params.AssigneeID = parseUUID(*req.Updates.AssigneeID)
- if !params.AssigneeID.Valid {
+ assigneeUUID, err := util.ParseUUID(*req.Updates.AssigneeID)
+ if err != nil {
continue
}
+ params.AssigneeID = assigneeUUID
} else {
params.AssigneeID = pgtype.UUID{Valid: false}
}
@@ -1389,9 +1520,12 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
if _, ok := rawUpdates["parent_issue_id"]; ok {
if req.Updates.ParentIssueID != nil {
- newParentID := parseUUID(*req.Updates.ParentIssueID)
+ newParentID, err := util.ParseUUID(*req.Updates.ParentIssueID)
+ if err != nil {
+ continue
+ }
// Cannot set self as parent.
- if uuidToString(newParentID) == issueID {
+ if newParentID == prevIssue.ID {
continue
}
// Validate parent exists in the same workspace.
@@ -1409,7 +1543,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
if err != nil || !ancestor.ParentIssueID.Valid {
break
}
- if uuidToString(ancestor.ParentIssueID) == issueID {
+ if ancestor.ParentIssueID == prevIssue.ID {
cycleDetected = true
break
}
@@ -1425,7 +1559,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawUpdates["project_id"]; ok {
if req.Updates.ProjectID != nil {
- params.ProjectID = parseUUID(*req.Updates.ProjectID)
+ projectUUID, err := util.ParseUUID(*req.Updates.ProjectID)
+ if err != nil {
+ continue
+ }
+ params.ProjectID = projectUUID
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
@@ -1512,11 +1650,19 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
}
workspaceID := h.resolveWorkspaceID(r)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
deleted := 0
for _, issueID := range req.IssueIDs {
+ issueUUID, err := util.ParseUUID(issueID)
+ if err != nil {
+ continue
+ }
issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
- ID: parseUUID(issueID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: issueUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
continue
@@ -1535,8 +1681,9 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
h.deleteS3Objects(r.Context(), attachmentURLs)
+ // Always emit the resolved UUID — frontend caches key by UUID.
actorType, actorID := h.resolveActor(r, userID, workspaceID)
- h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": issueID})
+ h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": uuidToString(issue.ID)})
deleted++
}
diff --git a/server/internal/handler/label.go b/server/internal/handler/label.go
new file mode 100644
index 0000000000..adf7fa6b16
--- /dev/null
+++ b/server/internal/handler/label.go
@@ -0,0 +1,447 @@
+package handler
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/multica-ai/multica/server/internal/logger"
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+ "github.com/multica-ai/multica/server/pkg/protocol"
+)
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type LabelResponse struct {
+ ID string `json:"id"`
+ WorkspaceID string `json:"workspace_id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+func labelToResponse(l db.IssueLabel) LabelResponse {
+ return LabelResponse{
+ ID: uuidToString(l.ID),
+ WorkspaceID: uuidToString(l.WorkspaceID),
+ Name: l.Name,
+ Color: l.Color,
+ CreatedAt: timestampToString(l.CreatedAt),
+ UpdatedAt: timestampToString(l.UpdatedAt),
+ }
+}
+
+func labelsToResponse(list []db.IssueLabel) []LabelResponse {
+ out := make([]LabelResponse, len(list))
+ for i, l := range list {
+ out[i] = labelToResponse(l)
+ }
+ return out
+}
+
+type CreateLabelRequest struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+type UpdateLabelRequest struct {
+ Name *string `json:"name"`
+ Color *string `json:"color"`
+}
+
+// 6-digit hex, with or without leading '#'.
+var hexColorRE = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`)
+
+// normalizeColor returns a canonical "#rrggbb" form or an error if invalid.
+//
+// LOAD-BEARING INVARIANT: LabelChip renders `style={{ backgroundColor: color }}`
+// directly in the frontend. If this regex is ever relaxed to accept arbitrary
+// CSS (named colors, `url(...)`, etc.), that inline style becomes an injection
+// surface. Keep the regex strict.
+func normalizeColor(c string) (string, error) {
+ c = strings.TrimSpace(c)
+ if !hexColorRE.MatchString(c) {
+ return "", errors.New("color must be a 6-digit hex value like #3b82f6")
+ }
+ if !strings.HasPrefix(c, "#") {
+ c = "#" + c
+ }
+ return strings.ToLower(c), nil
+}
+
+const maxLabelNameLen = 32
+
+// validateLabelName trims and validates a label name. Returns the trimmed
+// name or an error suitable for a 400 response.
+func validateLabelName(raw string) (string, error) {
+ name := strings.TrimSpace(raw)
+ if name == "" {
+ return "", errors.New("name is required")
+ }
+ if len(name) > maxLabelNameLen {
+ return "", errors.New("name must be 32 characters or fewer")
+ }
+ // TODO(labels): consider restricting to a charset that excludes newlines,
+ // tabs, and control characters. Emoji are left allowed — users can pick
+ // `🐛 bug` if they want. Tracked as a follow-up so we don't gate this PR.
+ return name, nil
+}
+
+// ---------------------------------------------------------------------------
+// Handlers — label CRUD
+// ---------------------------------------------------------------------------
+
+func (h *Handler) ListLabels(w http.ResponseWriter, r *http.Request) {
+ workspaceID := h.resolveWorkspaceID(r)
+ labels, err := h.Queries.ListLabels(r.Context(), parseUUID(workspaceID))
+ if err != nil {
+ slog.Warn("ListLabels failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to list labels")
+ return
+ }
+ resp := labelsToResponse(labels)
+ writeJSON(w, http.StatusOK, map[string]any{"labels": resp, "total": len(resp)})
+}
+
+func (h *Handler) GetLabel(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ workspaceID := h.resolveWorkspaceID(r)
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "label id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+ label, err := h.Queries.GetLabel(r.Context(), db.GetLabelParams{
+ ID: idUUID, WorkspaceID: wsUUID,
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "label not found")
+ return
+ }
+ slog.Warn("GetLabel failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to get label")
+ return
+ }
+ writeJSON(w, http.StatusOK, labelToResponse(label))
+}
+
+func (h *Handler) CreateLabel(w http.ResponseWriter, r *http.Request) {
+ var req CreateLabelRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+ name, err := validateLabelName(req.Name)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ color, err := normalizeColor(req.Color)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ workspaceID := h.resolveWorkspaceID(r)
+ userID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+
+ label, err := h.Queries.CreateLabel(r.Context(), db.CreateLabelParams{
+ WorkspaceID: parseUUID(workspaceID),
+ Name: name,
+ Color: color,
+ })
+ if err != nil {
+ if isUniqueViolation(err) {
+ writeError(w, http.StatusConflict, "a label with that name already exists")
+ return
+ }
+ slog.Warn("CreateLabel failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to create label")
+ return
+ }
+ resp := labelToResponse(label)
+ h.publish(protocol.EventLabelCreated, workspaceID, "member", userID, map[string]any{"label": resp})
+ writeJSON(w, http.StatusCreated, resp)
+}
+
+func (h *Handler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ workspaceID := h.resolveWorkspaceID(r)
+
+ var req UpdateLabelRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+ userID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "label id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+ params := db.UpdateLabelParams{
+ ID: idUUID,
+ WorkspaceID: wsUUID,
+ }
+ if req.Name != nil {
+ name, err := validateLabelName(*req.Name)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ params.Name = pgtype.Text{String: name, Valid: true}
+ }
+ if req.Color != nil {
+ color, err := normalizeColor(*req.Color)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ params.Color = pgtype.Text{String: color, Valid: true}
+ }
+
+ // Branch on pgx.ErrNoRows directly from the UPDATE — the WHERE clause
+ // already enforces (id, workspace_id), so a missing row means either the
+ // label doesn't exist or it's not in this workspace. Dropping the prior
+ // GetLabel precheck removes a TOCTOU window and saves a round-trip.
+ label, err := h.Queries.UpdateLabel(r.Context(), params)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "label not found")
+ return
+ }
+ if isUniqueViolation(err) {
+ writeError(w, http.StatusConflict, "a label with that name already exists")
+ return
+ }
+ slog.Warn("UpdateLabel failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to update label")
+ return
+ }
+ resp := labelToResponse(label)
+ h.publish(protocol.EventLabelUpdated, workspaceID, "member", userID, map[string]any{"label": resp})
+ writeJSON(w, http.StatusOK, resp)
+}
+
+func (h *Handler) DeleteLabel(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ workspaceID := h.resolveWorkspaceID(r)
+ userID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "label id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+ // DeleteLabel is :one RETURNING id — ErrNoRows means the label wasn't in
+ // this workspace (404). Any other error is a real 500.
+ if _, err := h.Queries.DeleteLabel(r.Context(), db.DeleteLabelParams{
+ ID: idUUID, WorkspaceID: wsUUID,
+ }); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "label not found")
+ return
+ }
+ slog.Warn("DeleteLabel failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to delete label")
+ return
+ }
+ h.publish(protocol.EventLabelDeleted, workspaceID, "member", userID, map[string]any{"label_id": uuidToString(idUUID)})
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// ---------------------------------------------------------------------------
+// Handlers — issue↔label attach/detach
+// ---------------------------------------------------------------------------
+
+type AttachLabelRequest struct {
+ LabelID string `json:"label_id"`
+}
+
+// listLabelsForIssueSafe reads the attached-label list and handles the error
+// by logging + returning nil. Callers use this after a successful attach/detach
+// mutation: if the read fails, the mutation is already committed, so returning
+// nil → clients refetch via query invalidation, and we skip broadcasting an
+// empty list that would incorrectly overwrite every subscriber's optimistic
+// state.
+func (h *Handler) listLabelsForIssueSafe(r *http.Request, issueID, workspaceID pgtype.UUID) ([]db.IssueLabel, bool) {
+ labels, err := h.Queries.ListLabelsByIssue(r.Context(), db.ListLabelsByIssueParams{
+ IssueID: issueID,
+ WorkspaceID: workspaceID,
+ })
+ if err != nil {
+ slog.Warn("ListLabelsByIssue failed after mutation", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issueID))...)
+ return nil, false
+ }
+ return labels, true
+}
+
+// ListLabelsForIssue returns the labels currently attached to an issue.
+func (h *Handler) ListLabelsForIssue(w http.ResponseWriter, r *http.Request) {
+ issueID := chi.URLParam(r, "id")
+ // Authorize via the issue — if it's not in this workspace, the caller
+ // shouldn't see its labels.
+ issue, ok := h.loadIssueForUser(w, r, issueID)
+ if !ok {
+ return
+ }
+ labels, err := h.Queries.ListLabelsByIssue(r.Context(), db.ListLabelsByIssueParams{
+ IssueID: issue.ID,
+ WorkspaceID: issue.WorkspaceID,
+ })
+ if err != nil {
+ slog.Warn("ListLabelsForIssue failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to list labels")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"labels": labelsToResponse(labels)})
+}
+
+// AttachLabel attaches a label to an issue.
+func (h *Handler) AttachLabel(w http.ResponseWriter, r *http.Request) {
+ issueID := chi.URLParam(r, "id")
+ userID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+
+ var req AttachLabelRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+ if req.LabelID == "" {
+ writeError(w, http.StatusBadRequest, "label_id is required")
+ return
+ }
+
+ // Both the issue and label must belong to this workspace.
+ issue, ok := h.loadIssueForUser(w, r, issueID)
+ if !ok {
+ return
+ }
+ labelID, ok := parseUUIDOrBadRequest(w, req.LabelID, "label_id")
+ if !ok {
+ return
+ }
+ if _, err := h.Queries.GetLabel(r.Context(), db.GetLabelParams{
+ ID: labelID, WorkspaceID: issue.WorkspaceID,
+ }); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "label not found")
+ return
+ }
+ slog.Warn("GetLabel in AttachLabel failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to attach label")
+ return
+ }
+
+ if err := h.Queries.AttachLabelToIssue(r.Context(), db.AttachLabelToIssueParams{
+ IssueID: issue.ID,
+ LabelID: labelID,
+ WorkspaceID: issue.WorkspaceID,
+ }); err != nil {
+ slog.Warn("AttachLabelToIssue failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to attach label")
+ return
+ }
+
+ // Read the updated label list; on read failure, the attach is already
+ // committed — return success without a labels body (clients refetch via
+ // query invalidation) and skip the broadcast so we don't overwrite every
+ // subscriber's optimistic state with an incorrect empty list.
+ labels, ok2 := h.listLabelsForIssueSafe(r, issue.ID, issue.WorkspaceID)
+ if !ok2 {
+ writeJSON(w, http.StatusOK, map[string]any{})
+ return
+ }
+ resp := labelsToResponse(labels)
+ h.publish(protocol.EventIssueLabelsChanged, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
+ "issue_id": uuidToString(issue.ID),
+ "labels": resp,
+ })
+ writeJSON(w, http.StatusOK, map[string]any{"labels": resp})
+}
+
+// DetachLabel removes a label from an issue.
+func (h *Handler) DetachLabel(w http.ResponseWriter, r *http.Request) {
+ issueID := chi.URLParam(r, "id")
+ labelID := chi.URLParam(r, "labelId")
+ userID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+
+ // Verify both issue and label belong to this workspace before detaching
+ // (mirror of AttachLabel). Without this, a crafted request with a foreign
+ // labelID would no-op and return 200 — "silent success" is worse than an
+ // explicit 404.
+ issue, ok := h.loadIssueForUser(w, r, issueID)
+ if !ok {
+ return
+ }
+ labelUUID, ok := parseUUIDOrBadRequest(w, labelID, "label id")
+ if !ok {
+ return
+ }
+ if _, err := h.Queries.GetLabel(r.Context(), db.GetLabelParams{
+ ID: labelUUID, WorkspaceID: issue.WorkspaceID,
+ }); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "label not found")
+ return
+ }
+ slog.Warn("GetLabel in DetachLabel failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to detach label")
+ return
+ }
+
+ if err := h.Queries.DetachLabelFromIssue(r.Context(), db.DetachLabelFromIssueParams{
+ IssueID: issue.ID,
+ LabelID: labelUUID,
+ WorkspaceID: issue.WorkspaceID,
+ }); err != nil {
+ slog.Warn("DetachLabelFromIssue failed", append(logger.RequestAttrs(r), "error", err)...)
+ writeError(w, http.StatusInternalServerError, "failed to detach label")
+ return
+ }
+
+ labels, ok2 := h.listLabelsForIssueSafe(r, issue.ID, issue.WorkspaceID)
+ if !ok2 {
+ writeJSON(w, http.StatusOK, map[string]any{})
+ return
+ }
+ resp := labelsToResponse(labels)
+ h.publish(protocol.EventIssueLabelsChanged, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
+ "issue_id": uuidToString(issue.ID),
+ "labels": resp,
+ })
+ writeJSON(w, http.StatusOK, map[string]any{"labels": resp})
+}
diff --git a/server/internal/handler/label_test.go b/server/internal/handler/label_test.go
new file mode 100644
index 0000000000..c5942ec2b2
--- /dev/null
+++ b/server/internal/handler/label_test.go
@@ -0,0 +1,438 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// TestLabelCRUD exercises label create/list/get/update/delete.
+func TestLabelCRUD(t *testing.T) {
+ // Create
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/labels", map[string]any{
+ "name": "bug",
+ "color": "#ef4444",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateLabel: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var created LabelResponse
+ json.NewDecoder(w.Body).Decode(&created)
+ if created.Name != "bug" || created.Color != "#ef4444" {
+ t.Fatalf("CreateLabel: unexpected payload: %+v", created)
+ }
+ labelID := created.ID
+
+ t.Cleanup(func() {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/labels/"+labelID, nil)
+ req = withURLParam(req, "id", labelID)
+ testHandler.DeleteLabel(w, req)
+ })
+
+ // Duplicate name → 409
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/labels", map[string]any{
+ "name": "BUG", // case-insensitive unique
+ "color": "#000000",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusConflict {
+ t.Fatalf("Duplicate CreateLabel: expected 409, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Invalid color → 400
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/labels", map[string]any{
+ "name": "enhancement",
+ "color": "nope",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("Invalid color: expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // List
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/labels", nil)
+ testHandler.ListLabels(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("ListLabels: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var listResp struct {
+ Labels []LabelResponse `json:"labels"`
+ Total int `json:"total"`
+ }
+ json.NewDecoder(w.Body).Decode(&listResp)
+ if listResp.Total < 1 {
+ t.Fatalf("ListLabels: expected >= 1 label, got %d", listResp.Total)
+ }
+
+ // Get
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/labels/"+labelID, nil)
+ req = withURLParam(req, "id", labelID)
+ testHandler.GetLabel(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GetLabel: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Update
+ w = httptest.NewRecorder()
+ req = newRequest("PUT", "/api/labels/"+labelID, map[string]any{
+ "name": "Bug (P0)",
+ "color": "#b91c1c",
+ })
+ req = withURLParam(req, "id", labelID)
+ testHandler.UpdateLabel(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("UpdateLabel: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var updated LabelResponse
+ json.NewDecoder(w.Body).Decode(&updated)
+ if updated.Name != "Bug (P0)" || updated.Color != "#b91c1c" {
+ t.Fatalf("UpdateLabel: unexpected payload: %+v", updated)
+ }
+}
+
+// TestIssueLabelAttachDetach exercises attach/detach + the issue-scoped endpoints.
+func TestIssueLabelAttachDetach(t *testing.T) {
+ // Create issue
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Issue for label attach test",
+ "status": "todo",
+ "priority": "medium",
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var issue IssueResponse
+ json.NewDecoder(w.Body).Decode(&issue)
+ issueID := issue.ID
+
+ // Create label
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/labels", map[string]any{
+ "name": "feature",
+ "color": "#3b82f6",
+ })
+ testHandler.CreateLabel(w, req)
+ var label LabelResponse
+ json.NewDecoder(w.Body).Decode(&label)
+ labelID := label.ID
+
+ t.Cleanup(func() {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/labels/"+labelID, nil)
+ req = withURLParam(req, "id", labelID)
+ testHandler.DeleteLabel(w, req)
+ })
+
+ // Attach
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/issues/"+issueID+"/labels", map[string]any{
+ "label_id": labelID,
+ })
+ req = withURLParam(req, "id", issueID)
+ testHandler.AttachLabel(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("AttachLabel: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Attach again (idempotent — ON CONFLICT DO NOTHING)
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/issues/"+issueID+"/labels", map[string]any{
+ "label_id": labelID,
+ })
+ req = withURLParam(req, "id", issueID)
+ testHandler.AttachLabel(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("AttachLabel (second): expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // List labels for issue
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/issues/"+issueID+"/labels", nil)
+ req = withURLParam(req, "id", issueID)
+ testHandler.ListLabelsForIssue(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("ListLabelsForIssue: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var issueLabels struct {
+ Labels []LabelResponse `json:"labels"`
+ }
+ json.NewDecoder(w.Body).Decode(&issueLabels)
+ if len(issueLabels.Labels) != 1 {
+ t.Fatalf("ListLabelsForIssue: expected 1 label, got %d", len(issueLabels.Labels))
+ }
+ if issueLabels.Labels[0].ID != labelID {
+ t.Fatalf("ListLabelsForIssue: wrong label returned: %+v", issueLabels.Labels[0])
+ }
+
+ // Detach
+ w = httptest.NewRecorder()
+ req = newRequest("DELETE", "/api/issues/"+issueID+"/labels/"+labelID, nil)
+ req = withURLParams(req, "id", issueID, "labelId", labelID)
+ testHandler.DetachLabel(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("DetachLabel: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Confirm detached
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/issues/"+issueID+"/labels", nil)
+ req = withURLParam(req, "id", issueID)
+ testHandler.ListLabelsForIssue(w, req)
+ json.NewDecoder(w.Body).Decode(&issueLabels)
+ if len(issueLabels.Labels) != 0 {
+ t.Fatalf("after Detach: expected 0 labels, got %d", len(issueLabels.Labels))
+ }
+}
+
+// TestLabelNotFoundAcrossWorkspaces ensures GET with a foreign workspace
+// header returns 404 — the query's `WHERE workspace_id = $2` does the work.
+func TestLabelNotFoundAcrossWorkspaces(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/labels", map[string]any{
+ "name": "cross-ws-test",
+ "color": "#a855f7",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateLabel: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var label LabelResponse
+ json.NewDecoder(w.Body).Decode(&label)
+ labelID := label.ID
+
+ t.Cleanup(func() {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/labels/"+labelID, nil)
+ req = withURLParam(req, "id", labelID)
+ testHandler.DeleteLabel(w, req)
+ })
+
+ // GET with a different workspace ID → 404
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/labels/"+labelID, nil)
+ req.Header.Set("X-Workspace-ID", "00000000-0000-0000-0000-000000000000")
+ req = withURLParam(req, "id", labelID)
+ testHandler.GetLabel(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("GetLabel cross-workspace: expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestUpdateLabelCrossWorkspace — PUT with a foreign workspace header must not
+// allow updating a label in another workspace (404 via pgx.ErrNoRows from the
+// UPDATE ... WHERE id = $1 AND workspace_id = $2 clause).
+func TestUpdateLabelCrossWorkspace(t *testing.T) {
+ // Create in real workspace
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/labels", map[string]any{
+ "name": "cross-ws-update-test",
+ "color": "#10b981",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateLabel: expected 201, got %d", w.Code)
+ }
+ var label LabelResponse
+ json.NewDecoder(w.Body).Decode(&label)
+ labelID := label.ID
+
+ t.Cleanup(func() {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/labels/"+labelID, nil)
+ req = withURLParam(req, "id", labelID)
+ testHandler.DeleteLabel(w, req)
+ })
+
+ // PUT with a foreign workspace ID → 404
+ w = httptest.NewRecorder()
+ req = newRequest("PUT", "/api/labels/"+labelID, map[string]any{"name": "hacked"})
+ req.Header.Set("X-Workspace-ID", "00000000-0000-0000-0000-000000000000")
+ req = withURLParam(req, "id", labelID)
+ testHandler.UpdateLabel(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("UpdateLabel cross-workspace: expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Sanity: the label wasn't renamed.
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/labels/"+labelID, nil)
+ req = withURLParam(req, "id", labelID)
+ testHandler.GetLabel(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GetLabel after failed cross-workspace PUT: expected 200, got %d", w.Code)
+ }
+ var after LabelResponse
+ json.NewDecoder(w.Body).Decode(&after)
+ if after.Name != "cross-ws-update-test" {
+ t.Fatalf("label name changed despite cross-workspace PUT: got %q", after.Name)
+ }
+}
+
+// TestAttachLabelCrossWorkspaceLabel — an attach request whose label_id
+// belongs to a different workspace must return 404, not silently no-op.
+// Directly exercises the GetLabel workspace precheck and the SQL-layer
+// defense-in-depth guard.
+func TestAttachLabelCrossWorkspaceLabel(t *testing.T) {
+ // Issue in the test workspace
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "cross-ws-attach-issue",
+ "status": "todo",
+ "priority": "medium",
+ })
+ testHandler.CreateIssue(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateIssue: expected 201, got %d", w.Code)
+ }
+ var issue IssueResponse
+ json.NewDecoder(w.Body).Decode(&issue)
+
+ // Label in a second workspace — insert directly via the pool to avoid
+ // the public API (which would require creating a full second workspace
+ // fixture). The defense-in-depth is exactly that the handler refuses
+ // even labels that exist *somewhere* but not in the current workspace.
+ otherWorkspaceID := createOtherTestWorkspace(t)
+ var otherLabelID string
+ err := testPool.QueryRow(context.Background(), `
+ INSERT INTO issue_label (workspace_id, name, color)
+ VALUES ($1, 'foreign-label', '#000000')
+ RETURNING id
+ `, otherWorkspaceID).Scan(&otherLabelID)
+ if err != nil {
+ t.Fatalf("insert foreign label: %v", err)
+ }
+
+ // Try to attach the foreign label to the test-workspace issue.
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/issues/"+issue.ID+"/labels", map[string]any{
+ "label_id": otherLabelID,
+ })
+ req = withURLParam(req, "id", issue.ID)
+ testHandler.AttachLabel(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("AttachLabel cross-workspace label: expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Confirm nothing was attached.
+ w = httptest.NewRecorder()
+ req = newRequest("GET", "/api/issues/"+issue.ID+"/labels", nil)
+ req = withURLParam(req, "id", issue.ID)
+ testHandler.ListLabelsForIssue(w, req)
+ var list struct {
+ Labels []LabelResponse `json:"labels"`
+ }
+ json.NewDecoder(w.Body).Decode(&list)
+ if len(list.Labels) != 0 {
+ t.Fatalf("expected 0 labels on issue, got %d", len(list.Labels))
+ }
+}
+
+// TestLabelNameTooLong — names longer than 64 chars must return 400.
+func TestLabelNameTooLong(t *testing.T) {
+ longName := strings.Repeat("a", 33)
+ w := httptest.NewRecorder()
+ req := newRequest("POST", "/api/labels", map[string]any{
+ "name": longName,
+ "color": "#123456",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreateLabel too-long name: expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Exactly 32 chars is fine.
+ okName := strings.Repeat("b", 32)
+ w = httptest.NewRecorder()
+ req = newRequest("POST", "/api/labels", map[string]any{
+ "name": okName,
+ "color": "#123456",
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateLabel 64-char name: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var created LabelResponse
+ json.NewDecoder(w.Body).Decode(&created)
+ t.Cleanup(func() {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/labels/"+created.ID, nil)
+ req = withURLParam(req, "id", created.ID)
+ testHandler.DeleteLabel(w, req)
+ })
+}
+
+// TestColorCaseNormalization — input `#ABCDEF` must be stored as `#abcdef`
+// so the case-insensitive uniqueness and downstream CSS rendering are
+// consistent. Also accepts a bare `ABCDEF` (no leading #).
+func TestColorCaseNormalization(t *testing.T) {
+ cases := []struct {
+ nameSuffix string
+ input string
+ want string
+ }{
+ {"upper", "#ABCDEF", "#abcdef"},
+ {"mixed", "#AbCdEf", "#abcdef"},
+ {"bare", "ABCDEF", "#abcdef"},
+ {"lower", "#123abc", "#123abc"},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ name := "color-norm-" + tc.nameSuffix // unique & case-independent
+ req := newRequest("POST", "/api/labels", map[string]any{
+ "name": name,
+ "color": tc.input,
+ })
+ testHandler.CreateLabel(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("CreateLabel %q: expected 201, got %d: %s", tc.input, w.Code, w.Body.String())
+ }
+ var got LabelResponse
+ json.NewDecoder(w.Body).Decode(&got)
+ if got.Color != tc.want {
+ t.Errorf("color normalization %q: got %q, want %q", tc.input, got.Color, tc.want)
+ }
+ t.Cleanup(func() {
+ w := httptest.NewRecorder()
+ req := newRequest("DELETE", "/api/labels/"+got.ID, nil)
+ req = withURLParam(req, "id", got.ID)
+ testHandler.DeleteLabel(w, req)
+ })
+ }
+}
+
+// createOtherTestWorkspace inserts a second workspace + owner membership for
+// cross-workspace tests. Returns the new workspace id; cleanup registered.
+func createOtherTestWorkspace(t *testing.T) string {
+ t.Helper()
+ ctx := context.Background()
+ var wsID string
+ err := testPool.QueryRow(ctx, `
+ INSERT INTO workspace (name, slug, description, issue_prefix)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id
+ `, "Other Handler Tests", handlerTestWorkspaceSlug+"-other", "temp second workspace", "OTH").Scan(&wsID)
+ if err != nil {
+ t.Fatalf("create other workspace: %v", err)
+ }
+ if _, err := testPool.Exec(ctx, `
+ INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')
+ `, wsID, testUserID); err != nil {
+ t.Fatalf("add member to other workspace: %v", err)
+ }
+ t.Cleanup(func() {
+ testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
+ })
+ return wsID
+}
diff --git a/server/internal/handler/onboarding.go b/server/internal/handler/onboarding.go
index 7a1c714b31..f77ef5e5c4 100644
--- a/server/internal/handler/onboarding.go
+++ b/server/internal/handler/onboarding.go
@@ -7,11 +7,11 @@ import (
"net/mail"
"strings"
- "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
+ "github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
@@ -356,16 +356,14 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
- // Reject malformed UUIDs up front. Without this, `parseUUID` below
- // silently returns a zero-UUID and the membership check fails with
- // a misleading 403 "not a member of this workspace" instead of the
- // true 400. Defense-in-depth: even if the membership check is ever
- // refactored, a garbage workspace_id never reaches CreateProject /
+ // Reject malformed UUIDs up front and reuse the parsed value for every
+ // write below so a garbage workspace_id never reaches CreateProject /
// CreateIssue.
- if _, err := uuid.Parse(req.WorkspaceID); err != nil {
- writeError(w, http.StatusBadRequest, "workspace_id is invalid")
+ wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
+ if !ok {
return
}
+ req.WorkspaceID = uuidToString(wsUUID)
if req.Project.Title == "" {
writeError(w, http.StatusBadRequest, "project.title is required")
return
@@ -406,7 +404,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
// since members are looked up by `user_id`.
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusForbidden, "not a member of this workspace")
return
@@ -418,7 +416,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
// workspace. `ListAgents` orders by created_at ASC, so "agents[0]"
// is deterministically the earliest-created agent. This replaces
// the old client-supplied `welcome_issue.agent_id` trust chain.
- agents, err := qtx.ListAgents(r.Context(), parseUUID(req.WorkspaceID))
+ agents, err := qtx.ListAgents(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agents")
return
@@ -435,7 +433,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
// --- Create project ---
project, err := qtx.CreateProject(r.Context(), db.CreateProjectParams{
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
Title: req.Project.Title,
Description: strOrNullText(req.Project.Description),
Icon: strOrNullText(req.Project.Icon),
@@ -452,7 +450,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
var welcomeIssueID *string
var welcomeIssueForEvent *db.Issue
if hasAgent && req.WelcomeIssueTemplate.Title != "" {
- welcomeNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(req.WorkspaceID))
+ welcomeNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
return
@@ -462,7 +460,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
priority = "high"
}
welcome, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
Title: req.WelcomeIssueTemplate.Title,
Description: strOrNullText(req.WelcomeIssueTemplate.Description),
Status: "todo",
@@ -490,7 +488,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
if sub.Title == "" {
continue
}
- number, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(req.WorkspaceID))
+ number, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
return
@@ -510,7 +508,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
priority = "none"
}
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
Title: sub.Title,
Description: strOrNullText(sub.Description),
Status: status,
@@ -538,7 +536,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
pinnedProjectPos := float64(1)
var pinProjectForEvent *db.PinnedItem
pinProject, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
UserID: parseUUID(userID),
ItemType: "project",
ItemID: project.ID,
@@ -552,7 +550,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
var pinWelcomeIssueForEvent *db.PinnedItem
if welcomeIssueForEvent != nil {
pinWelcome, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
UserID: parseUUID(userID),
ItemType: "issue",
ItemID: welcomeIssueForEvent.ID,
@@ -587,7 +585,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) {
projectResp := projectToResponse(project)
h.publish(protocol.EventProjectCreated, req.WorkspaceID, "member", userID, map[string]any{"project": projectResp})
- workspacePrefix := h.getIssuePrefix(r.Context(), parseUUID(req.WorkspaceID))
+ workspacePrefix := h.getIssuePrefix(r.Context(), wsUUID)
if welcomeIssueForEvent != nil {
welcomeResp := issueToResponse(*welcomeIssueForEvent, workspacePrefix)
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": welcomeResp})
@@ -675,12 +673,13 @@ func (h *Handler) DismissStarterContent(w http.ResponseWriter, r *http.Request)
// ListAgents returns empty.
branch := analytics.StarterContentBranchSelfServe
if req.WorkspaceID != "" {
- if _, err := uuid.Parse(req.WorkspaceID); err == nil {
+ if wsUUID, err := util.ParseUUID(req.WorkspaceID); err == nil {
+ req.WorkspaceID = uuidToString(wsUUID)
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
- WorkspaceID: parseUUID(req.WorkspaceID),
+ WorkspaceID: wsUUID,
}); err == nil {
- agents, err := h.Queries.ListAgents(r.Context(), parseUUID(req.WorkspaceID))
+ agents, err := h.Queries.ListAgents(r.Context(), wsUUID)
if err == nil && len(agents) > 0 {
branch = analytics.StarterContentBranchAgentGuided
}
diff --git a/server/internal/handler/personal_access_token.go b/server/internal/handler/personal_access_token.go
index 7f6401acac..f074aa442b 100644
--- a/server/internal/handler/personal_access_token.go
+++ b/server/internal/handler/personal_access_token.go
@@ -37,8 +37,8 @@ func patToResponse(pat db.PersonalAccessToken) PersonalAccessTokenResponse {
}
type CreatePATRequest struct {
- Name string `json:"name"`
- ExpiresInDays *int `json:"expires_in_days"`
+ Name string `json:"name"`
+ ExpiresInDays *int `json:"expires_in_days"`
}
func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Request) {
@@ -77,11 +77,11 @@ func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Reque
}
pat, err := h.Queries.CreatePersonalAccessToken(r.Context(), db.CreatePersonalAccessTokenParams{
- UserID: parseUUID(userID),
- Name: req.Name,
- TokenHash: auth.HashToken(rawToken),
+ UserID: parseUUID(userID),
+ Name: req.Name,
+ TokenHash: auth.HashToken(rawToken),
TokenPrefix: prefix,
- ExpiresAt: expiresAt,
+ ExpiresAt: expiresAt,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create token")
@@ -120,8 +120,12 @@ func (h *Handler) RevokePersonalAccessToken(w http.ResponseWriter, r *http.Reque
}
id := chi.URLParam(r, "id")
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "token id")
+ if !ok {
+ return
+ }
if err := h.Queries.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{
- ID: parseUUID(id),
+ ID: idUUID,
UserID: parseUUID(userID),
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke token")
diff --git a/server/internal/handler/pin.go b/server/internal/handler/pin.go
index 6493fc2a4b..ab4a84ca1c 100644
--- a/server/internal/handler/pin.go
+++ b/server/internal/handler/pin.go
@@ -93,18 +93,27 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) {
return
}
+ itemUUID, ok := parseUUIDOrBadRequest(w, req.ItemID, "item_id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
// Verify the item exists in this workspace
switch req.ItemType {
case "issue":
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
- ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID),
+ ID: itemUUID, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "issue not found")
return
}
case "project":
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
- ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID),
+ ID: itemUUID, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
@@ -113,7 +122,7 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) {
// Get max position to append at end
maxPos, err := h.Queries.GetMaxPinnedItemPosition(r.Context(), db.GetMaxPinnedItemPositionParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
UserID: parseUUID(userID),
})
if err != nil {
@@ -122,10 +131,10 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) {
}
pin, err := h.Queries.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
UserID: parseUUID(userID),
ItemType: req.ItemType,
- ItemID: parseUUID(req.ItemID),
+ ItemID: itemUUID,
Position: maxPos + 1,
})
if err != nil {
@@ -151,11 +160,20 @@ func (h *Handler) DeletePin(w http.ResponseWriter, r *http.Request) {
itemType := chi.URLParam(r, "itemType")
itemID := chi.URLParam(r, "itemId")
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+ itemUUID, ok := parseUUIDOrBadRequest(w, itemID, "item id")
+ if !ok {
+ return
+ }
+
err := h.Queries.DeletePinnedItem(r.Context(), db.DeletePinnedItemParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
UserID: parseUUID(userID),
ItemType: itemType,
- ItemID: parseUUID(itemID),
+ ItemID: itemUUID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete pin")
@@ -182,11 +200,20 @@ func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) {
return
}
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+
for _, item := range req.Items {
+ itemUUID, ok := parseUUIDOrBadRequest(w, item.ID, "items[].id")
+ if !ok {
+ return
+ }
if err := h.Queries.UpdatePinnedItemPosition(r.Context(), db.UpdatePinnedItemPositionParams{
Position: item.Position,
- ID: parseUUID(item.ID),
- WorkspaceID: parseUUID(workspaceID),
+ ID: itemUUID,
+ WorkspaceID: wsUUID,
UserID: parseUUID(userID),
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to reorder pins")
diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go
index bd4ee6694c..1626e9fec7 100644
--- a/server/internal/handler/project.go
+++ b/server/internal/handler/project.go
@@ -78,6 +78,10 @@ type UpdateProjectRequest struct {
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
@@ -87,7 +91,7 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
priorityFilter = pgtype.Text{String: p, Valid: true}
}
projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
Status: statusFilter,
Priority: priorityFilter,
})
@@ -125,8 +129,16 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "project id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
- ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
+ ID: idUUID, WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
@@ -166,10 +178,18 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
leadType = pgtype.Text{String: *req.LeadType, Valid: true}
}
if req.LeadID != nil {
- leadID = parseUUID(*req.LeadID)
+ id, ok := parseUUIDOrBadRequest(w, *req.LeadID, "lead_id")
+ if !ok {
+ return
+ }
+ leadID = id
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
}
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
Title: req.Title,
Description: ptrToText(req.Description),
Icon: ptrToText(req.Icon),
@@ -190,8 +210,16 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "project id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
prevProject, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
- ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
+ ID: idUUID, WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
@@ -253,7 +281,11 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
}
if _, ok := rawFields["lead_id"]; ok {
if req.LeadID != nil {
- params.LeadID = parseUUID(*req.LeadID)
+ leadUUID, ok := parseUUIDOrBadRequest(w, *req.LeadID, "lead_id")
+ if !ok {
+ return
+ }
+ params.LeadID = leadUUID
} else {
params.LeadID = pgtype.UUID{Valid: false}
}
@@ -271,9 +303,18 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := h.resolveWorkspaceID(r)
- if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
- ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
- }); err != nil {
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "project id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
+ project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
+ ID: idUUID, WorkspaceID: wsUUID,
+ })
+ if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
@@ -281,11 +322,11 @@ func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if err := h.Queries.DeleteProject(r.Context(), parseUUID(id)); err != nil {
+ if err := h.Queries.DeleteProject(r.Context(), project.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project")
return
}
- h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": id})
+ h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": uuidToString(project.ID)})
w.WriteHeader(http.StatusNoContent)
}
@@ -453,7 +494,10 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) {
includeClosed := r.URL.Query().Get("include_closed") == "true"
- wsUUID := parseUUID(workspaceID)
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
terms := splitSearchTerms(q)
sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed)
diff --git a/server/internal/handler/reaction.go b/server/internal/handler/reaction.go
index 46857bd4e9..26e23d657e 100644
--- a/server/internal/handler/reaction.go
+++ b/server/internal/handler/reaction.go
@@ -41,9 +41,17 @@ func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) {
}
workspaceID := h.resolveWorkspaceID(r)
+ commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
- ID: parseUUID(commentId),
- WorkspaceID: parseUUID(workspaceID),
+ ID: commentUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
@@ -66,7 +74,7 @@ func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) {
reaction, err := h.Queries.AddReaction(r.Context(), db.AddReactionParams{
CommentID: comment.ID,
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: wsUUID,
ActorType: actorType,
ActorID: parseUUID(actorID),
Emoji: req.Emoji,
@@ -108,9 +116,17 @@ func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
}
workspaceID := h.resolveWorkspaceID(r)
+ commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
+ if !ok {
+ return
+ }
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
- ID: parseUUID(commentId),
- WorkspaceID: parseUUID(workspaceID),
+ ID: commentUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "comment not found")
@@ -143,7 +159,7 @@ func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
}
h.publish(protocol.EventReactionRemoved, workspaceID, actorType, actorID, map[string]any{
- "comment_id": commentId,
+ "comment_id": uuidToString(comment.ID),
"issue_id": uuidToString(comment.IssueID),
"emoji": req.Emoji,
"actor_type": actorType,
diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go
index 1b9c10acef..ca06225247 100644
--- a/server/internal/handler/runtime.go
+++ b/server/internal/handler/runtime.go
@@ -79,8 +79,12 @@ type RuntimeUsageResponse struct {
// same tool).
func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return
+ }
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
@@ -93,7 +97,7 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
since := parseSinceParam(r, 90)
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
- RuntimeID: parseUUID(runtimeID),
+ RuntimeID: rt.ID,
Since: since,
})
if err != nil {
@@ -102,9 +106,10 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
}
resp := make([]RuntimeUsageResponse, len(rows))
+ resolvedRuntimeID := uuidToString(rt.ID)
for i, row := range rows {
resp[i] = RuntimeUsageResponse{
- RuntimeID: runtimeID,
+ RuntimeID: resolvedRuntimeID,
Date: row.Date.Time.Format("2006-01-02"),
Provider: row.Provider,
Model: row.Model,
@@ -121,8 +126,12 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
// GetRuntimeTaskActivity returns hourly task activity distribution for a runtime.
func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return
+ }
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
@@ -132,7 +141,7 @@ func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request)
return
}
- rows, err := h.Queries.GetRuntimeTaskHourlyActivity(r.Context(), parseUUID(runtimeID))
+ rows, err := h.Queries.GetRuntimeTaskHourlyActivity(r.Context(), rt.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get task activity")
return
@@ -387,8 +396,12 @@ func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) {
// DeleteAgentRuntime deletes a runtime after permission and dependency checks.
func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return
+ }
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
@@ -431,7 +444,7 @@ func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) {
return
}
- slog.Info("runtime deleted", "runtime_id", runtimeID, "deleted_by", userID)
+ slog.Info("runtime deleted", "runtime_id", uuidToString(rt.ID), "deleted_by", userID)
// Notify frontend to refresh runtime list.
h.publish(protocol.EventDaemonRegister, wsID, "member", userID, map[string]any{
diff --git a/server/internal/handler/runtime_local_skills.go b/server/internal/handler/runtime_local_skills.go
index 18059ae17d..f80c75fdb1 100644
--- a/server/internal/handler/runtime_local_skills.go
+++ b/server/internal/handler/runtime_local_skills.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
+ "github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/protocol"
)
@@ -387,7 +388,12 @@ func runtimeLocalSkillRequestTerminal(status RuntimeLocalSkillRequestStatus) boo
}
func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (runtimeIDAndWorkspace, bool) {
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return runtimeIDAndWorkspace{}, false
+ }
+
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return runtimeIDAndWorkspace{}, false
@@ -401,7 +407,7 @@ func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http.
if rt.OwnerID.Valid && uuidToString(rt.OwnerID) == uuidToString(member.UserID) {
return runtimeIDAndWorkspace{
- runtimeID: runtimeID,
+ runtimeID: uuidToString(rt.ID),
workspaceID: wsID,
provider: rt.Provider,
status: rt.Status,
@@ -430,7 +436,7 @@ func (h *Handler) InitiateListLocalSkills(w http.ResponseWriter, r *http.Request
return
}
- req, err := h.LocalSkillListStore.Create(r.Context(), runtimeID)
+ req, err := h.LocalSkillListStore.Create(r.Context(), rt.runtimeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enqueue local skills request: "+err.Error())
return
@@ -440,7 +446,8 @@ func (h *Handler) InitiateListLocalSkills(w http.ResponseWriter, r *http.Request
func (h *Handler) GetLocalSkillListRequest(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
- if _, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID); !ok {
+ rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID)
+ if !ok {
return
}
@@ -450,7 +457,7 @@ func (h *Handler) GetLocalSkillListRequest(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error())
return
}
- if req == nil || req.RuntimeID != runtimeID {
+ if req == nil || req.RuntimeID != rt.runtimeID {
writeError(w, http.StatusNotFound, "request not found")
return
}
@@ -486,7 +493,7 @@ func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Reques
importReq, err := h.LocalSkillImportStore.Create(
r.Context(),
- runtimeID,
+ rt.runtimeID,
creatorID,
strings.TrimSpace(req.SkillKey),
cleanOptionalString(req.Name),
@@ -501,7 +508,8 @@ func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Reques
func (h *Handler) GetLocalSkillImportRequest(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
- if _, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID); !ok {
+ rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID)
+ if !ok {
return
}
@@ -511,7 +519,7 @@ func (h *Handler) GetLocalSkillImportRequest(w http.ResponseWriter, r *http.Requ
writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error())
return
}
- if req == nil || req.RuntimeID != runtimeID {
+ if req == nil || req.RuntimeID != rt.runtimeID {
writeError(w, http.StatusNotFound, "request not found")
return
}
@@ -629,6 +637,15 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
+ creatorUUID, err := util.ParseUUID(req.CreatorID)
+ if err != nil {
+ failMsg := "stored local skill import creator_id is invalid"
+ if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
+ slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
+ }
+ writeError(w, http.StatusInternalServerError, failMsg)
+ return
+ }
name := body.Skill.Name
if req.Name != nil {
@@ -648,8 +665,8 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
- WorkspaceID: uuidToString(rt.WorkspaceID),
- CreatorID: req.CreatorID,
+ WorkspaceID: rt.WorkspaceID,
+ CreatorID: creatorUUID,
Name: name,
Description: description,
Content: body.Skill.Content,
diff --git a/server/internal/handler/runtime_models.go b/server/internal/handler/runtime_models.go
index 0177ad25d2..ca3a0707cb 100644
--- a/server/internal/handler/runtime_models.go
+++ b/server/internal/handler/runtime_models.go
@@ -193,8 +193,12 @@ func (s *ModelListStore) Fail(id string, errMsg string) {
// Called by the frontend; the daemon picks it up on its next heartbeat.
func (h *Handler) InitiateListModels(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return
+ }
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
@@ -207,7 +211,7 @@ func (h *Handler) InitiateListModels(w http.ResponseWriter, r *http.Request) {
return
}
- req := h.ModelListStore.Create(runtimeID)
+ req := h.ModelListStore.Create(uuidToString(rt.ID))
writeJSON(w, http.StatusOK, req)
}
diff --git a/server/internal/handler/runtime_test.go b/server/internal/handler/runtime_test.go
index 1d4c041ff7..080e5818a5 100644
--- a/server/internal/handler/runtime_test.go
+++ b/server/internal/handler/runtime_test.go
@@ -9,6 +9,64 @@ import (
"time"
)
+func TestRuntimeHandlersRejectMalformedRuntimeID(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ path string
+ handle func(http.ResponseWriter, *http.Request)
+ }{
+ {
+ name: "usage",
+ method: "GET",
+ path: "/api/runtimes/not-a-uuid/usage",
+ handle: testHandler.GetRuntimeUsage,
+ },
+ {
+ name: "task activity",
+ method: "GET",
+ path: "/api/runtimes/not-a-uuid/task-activity",
+ handle: testHandler.GetRuntimeTaskActivity,
+ },
+ {
+ name: "delete",
+ method: "DELETE",
+ path: "/api/runtimes/not-a-uuid",
+ handle: testHandler.DeleteAgentRuntime,
+ },
+ {
+ name: "models",
+ method: "POST",
+ path: "/api/runtimes/not-a-uuid/models",
+ handle: testHandler.InitiateListModels,
+ },
+ {
+ name: "update",
+ method: "POST",
+ path: "/api/runtimes/not-a-uuid/update",
+ handle: testHandler.InitiateUpdate,
+ },
+ {
+ name: "local skills",
+ method: "POST",
+ path: "/api/runtimes/not-a-uuid/local-skills",
+ handle: testHandler.InitiateListLocalSkills,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := newRequest(tt.method, tt.path, nil)
+ req = withURLParam(req, "runtimeId", "not-a-uuid")
+ tt.handle(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("%s: expected 400 for malformed runtimeId, got %d: %s", tt.name, w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
// TestGetRuntimeUsage_BucketsByUsageTime ensures a task that was enqueued on
// one calendar day but whose tokens were reported the next day (e.g. execution
// crossed midnight, or the task sat in the queue) is attributed to the day
@@ -78,7 +136,7 @@ func TestGetRuntimeUsage_BucketsByUsageTime(t *testing.T) {
return taskID
}
- insertTaskWithUsage(yesterdayLate, todayEarly, 1000) // cross-midnight
+ insertTaskWithUsage(yesterdayLate, todayEarly, 1000) // cross-midnight
insertTaskWithUsage(yesterdayMorning, yesterdayMorning, 2000) // full-day yesterday
// Call the handler with ?days=1 at whatever "now" is. That should include
diff --git a/server/internal/handler/runtime_update.go b/server/internal/handler/runtime_update.go
index d1f4dd8e5a..198cdde975 100644
--- a/server/internal/handler/runtime_update.go
+++ b/server/internal/handler/runtime_update.go
@@ -144,8 +144,12 @@ func (s *UpdateStore) Fail(id string, errMsg string) {
// InitiateUpdate creates a new CLI update request (protected route, called by frontend).
func (h *Handler) InitiateUpdate(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
+ runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
+ if !ok {
+ return
+ }
- rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID))
+ rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
@@ -167,7 +171,7 @@ func (h *Handler) InitiateUpdate(w http.ResponseWriter, r *http.Request) {
return
}
- update, err := h.UpdateStore.Create(runtimeID, req.TargetVersion)
+ update, err := h.UpdateStore.Create(uuidToString(rt.ID), req.TargetVersion)
if err != nil {
writeError(w, http.StatusConflict, err.Error())
return
diff --git a/server/internal/handler/skill.go b/server/internal/handler/skill.go
index 67ab1005b7..05005eb1e0 100644
--- a/server/internal/handler/skill.go
+++ b/server/internal/handler/skill.go
@@ -190,6 +190,11 @@ func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+ creatorUUID := parseUUID(creatorID)
var req CreateSkillRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -210,8 +215,8 @@ func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) {
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
- WorkspaceID: workspaceID,
- CreatorID: creatorID,
+ WorkspaceID: workspaceUUID,
+ CreatorID: creatorUUID,
Name: req.Name,
Description: req.Description,
Content: req.Content,
@@ -360,12 +365,12 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.Queries.DeleteSkill(r.Context(), parseUUID(id)); err != nil {
+ if err := h.Queries.DeleteSkill(r.Context(), skill.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete skill")
return
}
actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(skill.WorkspaceID))
- h.publish(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), actorType, actorID, map[string]any{"skill_id": id})
+ h.publish(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), actorType, actorID, map[string]any{"skill_id": uuidToString(skill.ID)})
w.WriteHeader(http.StatusNoContent)
}
@@ -1073,6 +1078,11 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+ creatorUUID := parseUUID(creatorID)
var req ImportSkillRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -1112,8 +1122,8 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
- WorkspaceID: workspaceID,
- CreatorID: creatorID,
+ WorkspaceID: workspaceUUID,
+ CreatorID: creatorUUID,
Name: imported.name,
Description: imported.description,
Content: imported.content,
@@ -1200,7 +1210,18 @@ func (h *Handler) DeleteSkillFile(w http.ResponseWriter, r *http.Request) {
}
fileID := chi.URLParam(r, "fileId")
- if err := h.Queries.DeleteSkillFile(r.Context(), parseUUID(fileID)); err != nil {
+ fileUUID, ok := parseUUIDOrBadRequest(w, fileID, "file id")
+ if !ok {
+ return
+ }
+ // Verify the file belongs to the parent skill we just authorized — guards
+ // against deleting a file owned by a different skill via the URL param.
+ file, err := h.Queries.GetSkillFile(r.Context(), fileUUID)
+ if err != nil || uuidToString(file.SkillID) != uuidToString(skill.ID) {
+ writeError(w, http.StatusNotFound, "skill file not found")
+ return
+ }
+ if err := h.Queries.DeleteSkillFile(r.Context(), file.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete skill file")
return
}
@@ -1244,6 +1265,10 @@ func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
+ skillUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.SkillIDs, "skill_ids")
+ if !ok {
+ return
+ }
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
@@ -1259,10 +1284,10 @@ func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) {
return
}
- for _, skillID := range req.SkillIDs {
+ for _, skillID := range skillUUIDs {
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
AgentID: agent.ID,
- SkillID: parseUUID(skillID),
+ SkillID: skillID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add agent skill: "+err.Error())
return
diff --git a/server/internal/handler/skill_create.go b/server/internal/handler/skill_create.go
index 0e3a70b285..7d8f159604 100644
--- a/server/internal/handler/skill_create.go
+++ b/server/internal/handler/skill_create.go
@@ -4,12 +4,13 @@ import (
"context"
"encoding/json"
+ "github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type skillCreateInput struct {
- WorkspaceID string
- CreatorID string
+ WorkspaceID pgtype.UUID
+ CreatorID pgtype.UUID
Name string
Description string
Content string
@@ -35,12 +36,12 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
qtx := h.Queries.WithTx(tx)
skill, err := qtx.CreateSkill(ctx, db.CreateSkillParams{
- WorkspaceID: parseUUID(input.WorkspaceID),
+ WorkspaceID: input.WorkspaceID,
Name: input.Name,
Description: input.Description,
Content: input.Content,
Config: config,
- CreatedBy: parseUUID(input.CreatorID),
+ CreatedBy: input.CreatorID,
})
if err != nil {
return SkillWithFilesResponse{}, err
diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go
index bf23c35ac0..c6c2222a97 100644
--- a/server/internal/handler/workspace.go
+++ b/server/internal/handler/workspace.go
@@ -114,8 +114,12 @@ func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
+ if !ok {
+ return
+ }
- ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
+ ws, err := h.Queries.GetWorkspace(r.Context(), idUUID)
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
@@ -223,6 +227,10 @@ type UpdateWorkspaceRequest struct {
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
+ idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
+ if !ok {
+ return
+ }
var req UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -231,7 +239,7 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
}
params := db.UpdateWorkspaceParams{
- ID: parseUUID(id),
+ ID: idUUID,
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
@@ -271,18 +279,19 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
slog.Info("workspace updated", append(logger.RequestAttrs(r), "workspace_id", id)...)
userID := requestUserID(r)
- h.publish(protocol.EventWorkspaceUpdated, id, "member", userID, map[string]any{"workspace": workspaceToResponse(ws)})
+ h.publish(protocol.EventWorkspaceUpdated, uuidToString(ws.ID), "member", userID, map[string]any{"workspace": workspaceToResponse(ws)})
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
}
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
- if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
+ member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
+ if !ok {
return
}
- members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID))
+ members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
@@ -309,8 +318,12 @@ type MemberWithUserResponse struct {
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
+ wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
+ if !ok {
+ return
+ }
- members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
+ members, err := h.Queries.ListMembersWithUser(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
@@ -413,7 +426,7 @@ func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
}
member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
- WorkspaceID: parseUUID(workspaceID),
+ WorkspaceID: requester.WorkspaceID,
UserID: user.ID,
Role: role,
})
@@ -430,10 +443,10 @@ func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
slog.Info("member added", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "email", email, "role", role)...)
userID := requestUserID(r)
eventPayload := map[string]any{"member": memberWithUserResponse(member, user)}
- if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID)); err == nil {
+ if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil {
eventPayload["workspace_name"] = ws.Name
}
- h.publish(protocol.EventMemberAdded, workspaceID, "member", userID, eventPayload)
+ h.publish(protocol.EventMemberAdded, uuidToString(requester.WorkspaceID), "member", userID, eventPayload)
writeJSON(w, http.StatusCreated, memberWithUserResponse(member, user))
}
@@ -450,8 +463,12 @@ func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
}
memberID := chi.URLParam(r, "memberId")
- target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID))
- if err != nil || uuidToString(target.WorkspaceID) != workspaceID {
+ memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
+ if !ok {
+ return
+ }
+ target, err := h.Queries.GetMember(r.Context(), memberUUID)
+ if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
writeError(w, http.StatusNotFound, "member not found")
return
}
@@ -505,7 +522,7 @@ func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
}
userID := requestUserID(r)
- h.publish(protocol.EventMemberUpdated, workspaceID, "member", userID, map[string]any{
+ h.publish(protocol.EventMemberUpdated, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{
"member": memberWithUserResponse(updatedMember, user),
})
@@ -520,8 +537,12 @@ func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
}
memberID := chi.URLParam(r, "memberId")
- target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID))
- if err != nil || uuidToString(target.WorkspaceID) != workspaceID {
+ memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
+ if !ok {
+ return
+ }
+ target, err := h.Queries.GetMember(r.Context(), memberUUID)
+ if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
writeError(w, http.StatusNotFound, "member not found")
return
}
@@ -551,9 +572,9 @@ func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(target.ID), "workspace_id", workspaceID, "user_id", uuidToString(target.UserID))...)
userID := requestUserID(r)
- h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{
+ h.publish(protocol.EventMemberRemoved, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{
"member_id": uuidToString(target.ID),
- "workspace_id": workspaceID,
+ "workspace_id": uuidToString(requester.WorkspaceID),
"user_id": uuidToString(target.UserID),
})
@@ -612,7 +633,9 @@ func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.Queries.DeleteWorkspace(r.Context(), parseUUID(workspaceID)); err != nil {
+ // At this point workspaceMember has resolved → workspaceID is a valid UUID
+ // (the lookup would have errored otherwise), so reuse the resolved value.
+ if err := h.Queries.DeleteWorkspace(r.Context(), requester.WorkspaceID); err != nil {
slog.Warn("delete workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
diff --git a/server/internal/metrics/config.go b/server/internal/metrics/config.go
new file mode 100644
index 0000000000..211aab1fa5
--- /dev/null
+++ b/server/internal/metrics/config.go
@@ -0,0 +1,35 @@
+package metrics
+
+import (
+ "net"
+ "os"
+ "strings"
+)
+
+type Config struct {
+ Addr string
+}
+
+func ConfigFromEnv() Config {
+ return Config{Addr: strings.TrimSpace(os.Getenv("METRICS_ADDR"))}
+}
+
+func (c Config) Enabled() bool {
+ return strings.TrimSpace(c.Addr) != ""
+}
+
+func IsLoopbackAddr(addr string) bool {
+ host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
+ if err != nil {
+ host = strings.TrimSpace(addr)
+ }
+ host = strings.Trim(host, "[]")
+ if host == "" {
+ return false
+ }
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
diff --git a/server/internal/metrics/config_test.go b/server/internal/metrics/config_test.go
new file mode 100644
index 0000000000..927a5c865f
--- /dev/null
+++ b/server/internal/metrics/config_test.go
@@ -0,0 +1,27 @@
+package metrics
+
+import "testing"
+
+func TestIsLoopbackAddr(t *testing.T) {
+ tests := []struct {
+ addr string
+ want bool
+ }{
+ {"127.0.0.1:9090", true},
+ {"localhost:9090", true},
+ {"[::1]:9090", true},
+ {":9090", false},
+ {"0.0.0.0:9090", false},
+ {"10.0.0.5:9090", false},
+ {"metrics.example.com:9090", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.addr, func(t *testing.T) {
+ if got := IsLoopbackAddr(tt.addr); got != tt.want {
+ t.Fatalf("IsLoopbackAddr(%q) = %v, want %v", tt.addr, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/server/internal/metrics/daemonws.go b/server/internal/metrics/daemonws.go
new file mode 100644
index 0000000000..d935ddc1ad
--- /dev/null
+++ b/server/internal/metrics/daemonws.go
@@ -0,0 +1,70 @@
+package metrics
+
+import (
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/multica-ai/multica/server/internal/daemonws"
+)
+
+type DaemonWSCollector struct {
+ metrics *daemonws.Metrics
+
+ connectsTotal *prometheus.Desc
+ disconnectsTotal *prometheus.Desc
+ activeConnections *prometheus.Desc
+ slowEvictionsTotal *prometheus.Desc
+ wakeupPublishedTotal *prometheus.Desc
+ wakeupPublishErrors *prometheus.Desc
+ wakeupReceivedTotal *prometheus.Desc
+ wakeupDeliveredTotal *prometheus.Desc
+}
+
+func NewDaemonWSCollector(m *daemonws.Metrics) *DaemonWSCollector {
+ return &DaemonWSCollector{
+ metrics: m,
+
+ connectsTotal: newDaemonWSDesc("connects_total", "Total daemon WebSocket connections opened."),
+ disconnectsTotal: newDaemonWSDesc("disconnects_total", "Total daemon WebSocket connections closed."),
+ activeConnections: newDaemonWSDesc("active_connections", "Current daemon WebSocket connections."),
+ slowEvictionsTotal: newDaemonWSDesc("slow_evictions_total", "Total daemon WebSocket clients evicted for slow consumption."),
+ wakeupPublishedTotal: newDaemonWSDesc("wakeup_published_total", "Total daemon wakeups published to the Redis relay."),
+ wakeupPublishErrors: newDaemonWSDesc("wakeup_publish_errors_total", "Total daemon wakeup Redis publish errors."),
+ wakeupReceivedTotal: newDaemonWSDesc("wakeup_received_total", "Total daemon wakeups received from the Redis relay."),
+ wakeupDeliveredTotal: prometheus.NewDesc("multica_daemonws_wakeup_delivered_total", "Total daemon wakeup local delivery attempts.", []string{"result"}, nil),
+ }
+}
+
+func newDaemonWSDesc(name, help string) *prometheus.Desc {
+ return prometheus.NewDesc("multica_daemonws_"+name, help, nil, nil)
+}
+
+func (c *DaemonWSCollector) Describe(ch chan<- *prometheus.Desc) {
+ for _, desc := range []*prometheus.Desc{
+ c.connectsTotal,
+ c.disconnectsTotal,
+ c.activeConnections,
+ c.slowEvictionsTotal,
+ c.wakeupPublishedTotal,
+ c.wakeupPublishErrors,
+ c.wakeupReceivedTotal,
+ c.wakeupDeliveredTotal,
+ } {
+ ch <- desc
+ }
+}
+
+func (c *DaemonWSCollector) Collect(ch chan<- prometheus.Metric) {
+ if c.metrics == nil {
+ return
+ }
+ m := c.metrics
+ ch <- prometheus.MustNewConstMetric(c.connectsTotal, prometheus.CounterValue, float64(m.ConnectsTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.disconnectsTotal, prometheus.CounterValue, float64(m.DisconnectsTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.activeConnections, prometheus.GaugeValue, float64(m.ActiveConnections.Load()))
+ ch <- prometheus.MustNewConstMetric(c.slowEvictionsTotal, prometheus.CounterValue, float64(m.SlowEvictionsTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.wakeupPublishedTotal, prometheus.CounterValue, float64(m.WakeupPublishedTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.wakeupPublishErrors, prometheus.CounterValue, float64(m.WakeupPublishErrors.Load()))
+ ch <- prometheus.MustNewConstMetric(c.wakeupReceivedTotal, prometheus.CounterValue, float64(m.WakeupReceivedTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.wakeupDeliveredTotal, prometheus.CounterValue, float64(m.WakeupDeliveredHit.Load()), "hit")
+ ch <- prometheus.MustNewConstMetric(c.wakeupDeliveredTotal, prometheus.CounterValue, float64(m.WakeupDeliveredMiss.Load()), "miss")
+}
diff --git a/server/internal/metrics/db.go b/server/internal/metrics/db.go
new file mode 100644
index 0000000000..8a84588e2b
--- /dev/null
+++ b/server/internal/metrics/db.go
@@ -0,0 +1,88 @@
+package metrics
+
+import (
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+type DBCollector struct {
+ pool *pgxpool.Pool
+
+ acquiredConns *prometheus.Desc
+ idleConns *prometheus.Desc
+ maxConns *prometheus.Desc
+ totalConns *prometheus.Desc
+ constructingConns *prometheus.Desc
+ acquireCount *prometheus.Desc
+ acquireDuration *prometheus.Desc
+ emptyAcquireCount *prometheus.Desc
+ emptyAcquireWaitTime *prometheus.Desc
+ canceledAcquireCount *prometheus.Desc
+ newConnsCount *prometheus.Desc
+ maxIdleDestroyCount *prometheus.Desc
+ maxLifetimeDestroyCnt *prometheus.Desc
+}
+
+func NewDBCollector(pool *pgxpool.Pool) *DBCollector {
+ return &DBCollector{
+ pool: pool,
+
+ acquiredConns: newDBDesc("acquired_conns", "Currently acquired PostgreSQL connections."),
+ idleConns: newDBDesc("idle_conns", "Currently idle PostgreSQL connections."),
+ maxConns: newDBDesc("max_conns", "Maximum PostgreSQL connections allowed by the pool."),
+ totalConns: newDBDesc("total_conns", "Total PostgreSQL connections currently in the pool."),
+ constructingConns: newDBDesc("constructing_conns", "PostgreSQL connections currently being established."),
+ acquireCount: newDBDesc("acquire_count", "Total successful PostgreSQL connection acquires."),
+ acquireDuration: newDBDesc("acquire_duration_seconds_total", "Total time spent acquiring PostgreSQL connections."),
+ emptyAcquireCount: newDBDesc("empty_acquire_count", "Total acquires that waited because the PostgreSQL pool was empty."),
+ emptyAcquireWaitTime: newDBDesc("empty_acquire_wait_seconds_total", "Total time spent waiting for PostgreSQL connections when the pool was empty."),
+ canceledAcquireCount: newDBDesc("canceled_acquire_count", "Total canceled PostgreSQL connection acquires."),
+ newConnsCount: newDBDesc("new_conns_count", "Total PostgreSQL connections created by the pool."),
+ maxIdleDestroyCount: newDBDesc("max_idle_destroy_count", "Total PostgreSQL connections destroyed due to idle limits."),
+ maxLifetimeDestroyCnt: newDBDesc("max_lifetime_destroy_count", "Total PostgreSQL connections destroyed due to max lifetime."),
+ }
+}
+
+func newDBDesc(name, help string) *prometheus.Desc {
+ return prometheus.NewDesc("multica_db_pool_"+name, help, nil, nil)
+}
+
+func (c *DBCollector) Describe(ch chan<- *prometheus.Desc) {
+ for _, desc := range []*prometheus.Desc{
+ c.acquiredConns,
+ c.idleConns,
+ c.maxConns,
+ c.totalConns,
+ c.constructingConns,
+ c.acquireCount,
+ c.acquireDuration,
+ c.emptyAcquireCount,
+ c.emptyAcquireWaitTime,
+ c.canceledAcquireCount,
+ c.newConnsCount,
+ c.maxIdleDestroyCount,
+ c.maxLifetimeDestroyCnt,
+ } {
+ ch <- desc
+ }
+}
+
+func (c *DBCollector) Collect(ch chan<- prometheus.Metric) {
+ if c.pool == nil {
+ return
+ }
+ stat := c.pool.Stat()
+ ch <- prometheus.MustNewConstMetric(c.acquiredConns, prometheus.GaugeValue, float64(stat.AcquiredConns()))
+ ch <- prometheus.MustNewConstMetric(c.idleConns, prometheus.GaugeValue, float64(stat.IdleConns()))
+ ch <- prometheus.MustNewConstMetric(c.maxConns, prometheus.GaugeValue, float64(stat.MaxConns()))
+ ch <- prometheus.MustNewConstMetric(c.totalConns, prometheus.GaugeValue, float64(stat.TotalConns()))
+ ch <- prometheus.MustNewConstMetric(c.constructingConns, prometheus.GaugeValue, float64(stat.ConstructingConns()))
+ ch <- prometheus.MustNewConstMetric(c.acquireCount, prometheus.CounterValue, float64(stat.AcquireCount()))
+ ch <- prometheus.MustNewConstMetric(c.acquireDuration, prometheus.CounterValue, stat.AcquireDuration().Seconds())
+ ch <- prometheus.MustNewConstMetric(c.emptyAcquireCount, prometheus.CounterValue, float64(stat.EmptyAcquireCount()))
+ ch <- prometheus.MustNewConstMetric(c.emptyAcquireWaitTime, prometheus.CounterValue, stat.EmptyAcquireWaitTime().Seconds())
+ ch <- prometheus.MustNewConstMetric(c.canceledAcquireCount, prometheus.CounterValue, float64(stat.CanceledAcquireCount()))
+ ch <- prometheus.MustNewConstMetric(c.newConnsCount, prometheus.CounterValue, float64(stat.NewConnsCount()))
+ ch <- prometheus.MustNewConstMetric(c.maxIdleDestroyCount, prometheus.CounterValue, float64(stat.MaxIdleDestroyCount()))
+ ch <- prometheus.MustNewConstMetric(c.maxLifetimeDestroyCnt, prometheus.CounterValue, float64(stat.MaxLifetimeDestroyCount()))
+}
diff --git a/server/internal/metrics/db_test.go b/server/internal/metrics/db_test.go
new file mode 100644
index 0000000000..2a0e6ca8c4
--- /dev/null
+++ b/server/internal/metrics/db_test.go
@@ -0,0 +1,35 @@
+package metrics
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func TestDBCollectorExposesPoolStats(t *testing.T) {
+ pool, err := pgxpool.New(context.Background(), "postgres://multica:multica@127.0.0.1:1/multica?sslmode=disable")
+ if err != nil {
+ t.Fatalf("create pool: %v", err)
+ }
+ defer pool.Close()
+
+ registry := NewRegistry(RegistryOptions{Pool: pool})
+ rec := httptest.NewRecorder()
+ NewHandler(registry.Gatherer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
+ body := rec.Body.String()
+
+ for _, want := range []string{
+ "multica_db_pool_acquired_conns",
+ "multica_db_pool_idle_conns",
+ "multica_db_pool_max_conns",
+ "multica_db_pool_acquire_duration_seconds_total",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("metrics body missing %q\n%s", want, body)
+ }
+ }
+}
diff --git a/server/internal/metrics/http.go b/server/internal/metrics/http.go
new file mode 100644
index 0000000000..5e957f2eb9
--- /dev/null
+++ b/server/internal/metrics/http.go
@@ -0,0 +1,94 @@
+package metrics
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ chimw "github.com/go-chi/chi/v5/middleware"
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+type HTTPMetrics struct {
+ requests *prometheus.CounterVec
+ duration *prometheus.HistogramVec
+ inFlight prometheus.Gauge
+}
+
+func NewHTTPMetrics() *HTTPMetrics {
+ return &HTTPMetrics{
+ requests: prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "multica",
+ Subsystem: "http",
+ Name: "requests_total",
+ Help: "Total HTTP requests served by the API server.",
+ }, []string{"method", "route", "status"}),
+ duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "multica",
+ Subsystem: "http",
+ Name: "request_duration_seconds",
+ Help: "HTTP request duration observed by the API server.",
+ Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
+ }, []string{"method", "route", "status"}),
+ inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: "multica",
+ Subsystem: "http",
+ Name: "in_flight_requests",
+ Help: "Current number of in-flight HTTP requests served by the API server.",
+ }),
+ }
+}
+
+func (m *HTTPMetrics) Collectors() []prometheus.Collector {
+ return []prometheus.Collector{m.requests, m.duration, m.inFlight}
+}
+
+func (m *HTTPMetrics) Middleware(next http.Handler) http.Handler {
+ if m == nil {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if isHealthProbePath(r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ m.inFlight.Inc()
+ defer m.inFlight.Dec()
+
+ start := time.Now()
+ ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
+ next.ServeHTTP(ww, r)
+
+ status := ww.Status()
+ if status == 0 {
+ status = http.StatusOK
+ }
+ labels := prometheus.Labels{
+ "method": r.Method,
+ "route": routePattern(r),
+ "status": strconv.Itoa(status),
+ }
+ m.requests.With(labels).Inc()
+ m.duration.With(labels).Observe(time.Since(start).Seconds())
+ })
+}
+
+func routePattern(r *http.Request) string {
+ if rctx := chi.RouteContext(r.Context()); rctx != nil {
+ if pattern := rctx.RoutePattern(); pattern != "" {
+ return pattern
+ }
+ }
+ return "unmatched"
+}
+
+func isHealthProbePath(path string) bool {
+ switch path {
+ case "/health", "/healthz", "/readyz":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/server/internal/metrics/http_test.go b/server/internal/metrics/http_test.go
new file mode 100644
index 0000000000..e57d332fbf
--- /dev/null
+++ b/server/internal/metrics/http_test.go
@@ -0,0 +1,100 @@
+package metrics
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func TestHTTPMiddlewareUsesRoutePatternLabels(t *testing.T) {
+ registry := NewRegistry(RegistryOptions{
+ Version: "v-test",
+ Commit: "abc123",
+ })
+
+ r := chi.NewRouter()
+ r.Use(registry.HTTP.Middleware)
+ r.Get("/api/issues/{id}", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/api/issues/secret-issue-id?token=secret-token", nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("request status = %d, want %d", rec.Code, http.StatusCreated)
+ }
+
+ metricsRec := httptest.NewRecorder()
+ NewHandler(registry.Gatherer).ServeHTTP(metricsRec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
+ body := metricsRec.Body.String()
+
+ for _, want := range []string{
+ `multica_http_requests_total{method="GET",route="/api/issues/{id}",status="201"} 1`,
+ `multica_build_info{commit="abc123",version="v-test"} 1`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("metrics body missing %q\n%s", want, body)
+ }
+ }
+ for _, leaked := range []string{"secret-issue-id", "secret-token"} {
+ if strings.Contains(body, leaked) {
+ t.Fatalf("metrics body leaked %q\n%s", leaked, body)
+ }
+ }
+}
+
+func TestMetricsHandlerOnlyServesMetricsPath(t *testing.T) {
+ registry := NewRegistry(RegistryOptions{})
+ handler := NewHandler(registry.Gatherer)
+
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("/metrics status = %d, want %d", rec.Code, http.StatusOK)
+ }
+ if body, _ := io.ReadAll(rec.Body); !strings.Contains(string(body), "multica_build_info") {
+ t.Fatalf("/metrics body missing build info: %s", body)
+ }
+
+ rec = httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("/health status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+}
+
+func TestHTTPMiddlewareSkipsHealthProbePaths(t *testing.T) {
+ registry := NewRegistry(RegistryOptions{})
+
+ r := chi.NewRouter()
+ r.Use(registry.HTTP.Middleware)
+ r.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ r.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+
+ for _, path := range []string{"/health", "/readyz"} {
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusOK)
+ }
+ }
+
+ metricsRec := httptest.NewRecorder()
+ NewHandler(registry.Gatherer).ServeHTTP(metricsRec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
+ body := metricsRec.Body.String()
+ for _, skippedRoute := range []string{`route="/health"`, `route="/readyz"`} {
+ if strings.Contains(body, skippedRoute) {
+ t.Fatalf("metrics body contains skipped health route %q\n%s", skippedRoute, body)
+ }
+ }
+}
diff --git a/server/internal/metrics/realtime.go b/server/internal/metrics/realtime.go
new file mode 100644
index 0000000000..45fa7fe7f9
--- /dev/null
+++ b/server/internal/metrics/realtime.go
@@ -0,0 +1,101 @@
+package metrics
+
+import (
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/multica-ai/multica/server/internal/realtime"
+)
+
+type RealtimeCollector struct {
+ metrics *realtime.Metrics
+
+ connectsTotal *prometheus.Desc
+ disconnectsTotal *prometheus.Desc
+ activeConnections *prometheus.Desc
+ slowEvictionsTotal *prometheus.Desc
+ messagesSentTotal *prometheus.Desc
+ messagesDropped *prometheus.Desc
+ redisConnected *prometheus.Desc
+ redisXAddTotal *prometheus.Desc
+ redisXAddErrors *prometheus.Desc
+ redisXReadTotal *prometheus.Desc
+ redisXReadErrors *prometheus.Desc
+ redisAckTotal *prometheus.Desc
+ redisMirrorErrors *prometheus.Desc
+ redisMirrorDiverged *prometheus.Desc
+}
+
+func NewRealtimeCollector(m *realtime.Metrics) *RealtimeCollector {
+ return &RealtimeCollector{
+ metrics: m,
+
+ connectsTotal: newRealtimeDesc("connects_total", "Total realtime WebSocket connections opened."),
+ disconnectsTotal: newRealtimeDesc("disconnects_total", "Total realtime WebSocket connections closed."),
+ activeConnections: newRealtimeDesc("active_connections", "Current realtime WebSocket connections."),
+ slowEvictionsTotal: newRealtimeDesc("slow_evictions_total", "Total realtime clients evicted for slow consumption."),
+ messagesSentTotal: newRealtimeDesc("messages_sent_total", "Total realtime messages sent."),
+ messagesDropped: newRealtimeDesc("messages_dropped_total", "Total realtime messages dropped."),
+ redisConnected: newRealtimeDesc("redis_connected", "Whether the realtime Redis relay is connected."),
+ redisXAddTotal: newRealtimeDesc("redis_xadd_total", "Total Redis XADD operations by the realtime relay."),
+ redisXAddErrors: newRealtimeDesc("redis_xadd_errors_total", "Total Redis XADD errors by the realtime relay."),
+ redisXReadTotal: newRealtimeDesc("redis_xread_total", "Total Redis XREAD operations by the realtime relay."),
+ redisXReadErrors: newRealtimeDesc("redis_xread_errors_total", "Total Redis XREAD errors by the realtime relay."),
+ redisAckTotal: newRealtimeDesc("redis_ack_total", "Total Redis stream acknowledgements by the realtime relay."),
+ redisMirrorErrors: prometheus.NewDesc("multica_realtime_redis_mirror_errors_total", "Total Redis mirror write errors by the realtime relay.", []string{"target"}, nil),
+ redisMirrorDiverged: newRealtimeDesc("redis_mirror_divergence_total", "Total Redis mirror divergence events by the realtime relay."),
+ }
+}
+
+func newRealtimeDesc(name, help string) *prometheus.Desc {
+ return prometheus.NewDesc("multica_realtime_"+name, help, nil, nil)
+}
+
+func (c *RealtimeCollector) Describe(ch chan<- *prometheus.Desc) {
+ for _, desc := range []*prometheus.Desc{
+ c.connectsTotal,
+ c.disconnectsTotal,
+ c.activeConnections,
+ c.slowEvictionsTotal,
+ c.messagesSentTotal,
+ c.messagesDropped,
+ c.redisConnected,
+ c.redisXAddTotal,
+ c.redisXAddErrors,
+ c.redisXReadTotal,
+ c.redisXReadErrors,
+ c.redisAckTotal,
+ c.redisMirrorErrors,
+ c.redisMirrorDiverged,
+ } {
+ ch <- desc
+ }
+}
+
+func (c *RealtimeCollector) Collect(ch chan<- prometheus.Metric) {
+ if c.metrics == nil {
+ return
+ }
+ m := c.metrics
+ ch <- prometheus.MustNewConstMetric(c.connectsTotal, prometheus.CounterValue, float64(m.ConnectsTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.disconnectsTotal, prometheus.CounterValue, float64(m.DisconnectsTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.activeConnections, prometheus.GaugeValue, float64(m.ActiveConnections.Load()))
+ ch <- prometheus.MustNewConstMetric(c.slowEvictionsTotal, prometheus.CounterValue, float64(m.SlowEvictionsTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.messagesSentTotal, prometheus.CounterValue, float64(m.MessagesSentTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.messagesDropped, prometheus.CounterValue, float64(m.MessagesDroppedTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisConnected, prometheus.GaugeValue, boolFloat(m.RedisConnected.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisXAddTotal, prometheus.CounterValue, float64(m.RedisXAddTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisXAddErrors, prometheus.CounterValue, float64(m.RedisXAddErrors.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisXReadTotal, prometheus.CounterValue, float64(m.RedisXReadTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisXReadErrors, prometheus.CounterValue, float64(m.RedisXReadErrors.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisAckTotal, prometheus.CounterValue, float64(m.RedisAckTotal.Load()))
+ ch <- prometheus.MustNewConstMetric(c.redisMirrorErrors, prometheus.CounterValue, float64(m.RedisMirrorPrimaryErrors.Load()), "primary")
+ ch <- prometheus.MustNewConstMetric(c.redisMirrorErrors, prometheus.CounterValue, float64(m.RedisMirrorSecondaryErrors.Load()), "secondary")
+ ch <- prometheus.MustNewConstMetric(c.redisMirrorDiverged, prometheus.CounterValue, float64(m.RedisMirrorDivergenceTotal.Load()))
+}
+
+func boolFloat(v bool) float64 {
+ if v {
+ return 1
+ }
+ return 0
+}
diff --git a/server/internal/metrics/realtime_test.go b/server/internal/metrics/realtime_test.go
new file mode 100644
index 0000000000..7799e27e06
--- /dev/null
+++ b/server/internal/metrics/realtime_test.go
@@ -0,0 +1,36 @@
+package metrics
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/multica-ai/multica/server/internal/realtime"
+)
+
+func TestRealtimeCollectorExposesCounters(t *testing.T) {
+ m := &realtime.Metrics{}
+ m.ActiveConnections.Store(3)
+ m.MessagesSentTotal.Store(11)
+ m.RedisConnected.Store(true)
+ m.RedisMirrorPrimaryErrors.Store(2)
+ m.RedisMirrorSecondaryErrors.Store(5)
+
+ registry := NewRegistry(RegistryOptions{Realtime: m})
+ rec := httptest.NewRecorder()
+ NewHandler(registry.Gatherer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
+ body := rec.Body.String()
+
+ for _, want := range []string{
+ "multica_realtime_active_connections 3",
+ "multica_realtime_messages_sent_total 11",
+ "multica_realtime_redis_connected 1",
+ `multica_realtime_redis_mirror_errors_total{target="primary"} 2`,
+ `multica_realtime_redis_mirror_errors_total{target="secondary"} 5`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("metrics body missing %q\n%s", want, body)
+ }
+ }
+}
diff --git a/server/internal/metrics/registry.go b/server/internal/metrics/registry.go
new file mode 100644
index 0000000000..776dd1ff29
--- /dev/null
+++ b/server/internal/metrics/registry.go
@@ -0,0 +1,64 @@
+package metrics
+
+import (
+ "strings"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/collectors"
+
+ "github.com/multica-ai/multica/server/internal/daemonws"
+ "github.com/multica-ai/multica/server/internal/realtime"
+)
+
+type RegistryOptions struct {
+ Pool *pgxpool.Pool
+ Realtime *realtime.Metrics
+ DaemonWS *daemonws.Metrics
+ Version string
+ Commit string
+}
+
+type Registry struct {
+ Gatherer prometheus.Gatherer
+ HTTP *HTTPMetrics
+}
+
+func NewRegistry(opts RegistryOptions) *Registry {
+ reg := prometheus.NewRegistry()
+ reg.MustRegister(collectors.NewGoCollector())
+ reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
+
+ buildInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "multica_build_info",
+ Help: "Build information for the Multica server binary.",
+ }, []string{"version", "commit"})
+ buildInfo.WithLabelValues(defaultLabel(opts.Version, "dev"), defaultLabel(opts.Commit, "unknown")).Set(1)
+ reg.MustRegister(buildInfo)
+
+ httpMetrics := NewHTTPMetrics()
+ reg.MustRegister(httpMetrics.Collectors()...)
+
+ if opts.Pool != nil {
+ reg.MustRegister(NewDBCollector(opts.Pool))
+ }
+ if opts.Realtime != nil {
+ reg.MustRegister(NewRealtimeCollector(opts.Realtime))
+ }
+ if opts.DaemonWS != nil {
+ reg.MustRegister(NewDaemonWSCollector(opts.DaemonWS))
+ }
+
+ return &Registry{
+ Gatherer: reg,
+ HTTP: httpMetrics,
+ }
+}
+
+func defaultLabel(value, fallback string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return fallback
+ }
+ return value
+}
diff --git a/server/internal/metrics/server.go b/server/internal/metrics/server.go
new file mode 100644
index 0000000000..d8fefc78b0
--- /dev/null
+++ b/server/internal/metrics/server.go
@@ -0,0 +1,29 @@
+package metrics
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+func NewHandler(gatherer prometheus.Gatherer) http.Handler {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{
+ EnableOpenMetrics: true,
+ ErrorHandling: promhttp.HTTPErrorOnError,
+ }))
+ return mux
+}
+
+func NewServer(addr string, gatherer prometheus.Gatherer) *http.Server {
+ return &http.Server{
+ Addr: addr,
+ Handler: NewHandler(gatherer),
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 10 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 30 * time.Second,
+ }
+}
diff --git a/server/internal/metrics/server_test.go b/server/internal/metrics/server_test.go
new file mode 100644
index 0000000000..fe3a61180e
--- /dev/null
+++ b/server/internal/metrics/server_test.go
@@ -0,0 +1,46 @@
+package metrics
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestMetricsServerCanBindLoopback(t *testing.T) {
+ registry := NewRegistry(RegistryOptions{})
+ server := NewServer("127.0.0.1:0", registry.Gatherer)
+ ln, err := net.Listen("tcp", server.Addr)
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- server.Serve(ln)
+ }()
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ _ = server.Shutdown(ctx)
+ if err := <-errCh; err != nil && err != http.ErrServerClosed {
+ t.Fatalf("serve: %v", err)
+ }
+ })
+
+ resp, err := http.Get("http://" + ln.Addr().String() + "/metrics")
+ if err != nil {
+ t.Fatalf("get /metrics: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("/metrics status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(body), "multica_build_info") {
+ t.Fatalf("/metrics body missing build info: %s", body)
+ }
+}
diff --git a/server/internal/middleware/workspace.go b/server/internal/middleware/workspace.go
index 13db651ba5..a30eaf8121 100644
--- a/server/internal/middleware/workspace.go
+++ b/server/internal/middleware/workspace.go
@@ -186,9 +186,19 @@ func buildMiddleware(queries *db.Queries, resolve workspaceResolver, roles []str
return
}
+ userUUID, err := util.ParseUUID(userID)
+ if err != nil {
+ writeError(w, http.StatusUnauthorized, "user not authenticated")
+ return
+ }
+ wsUUID, err := util.ParseUUID(workspaceID)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid workspace_id")
+ return
+ }
member, err := queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
- UserID: util.ParseUUID(userID),
- WorkspaceID: util.ParseUUID(workspaceID),
+ UserID: userUUID,
+ WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
diff --git a/server/internal/realtime/broadcaster.go b/server/internal/realtime/broadcaster.go
index a5aaba7ad3..a90c7ee67f 100644
--- a/server/internal/realtime/broadcaster.go
+++ b/server/internal/realtime/broadcaster.go
@@ -8,6 +8,9 @@ const (
ScopeUser = "user"
ScopeTask = "task"
ScopeChat = "chat"
+ // ScopeDaemonRuntime routes daemon wakeup frames through the Redis relay.
+ // It is consumed by the daemon WebSocket hub, not by browser clients.
+ ScopeDaemonRuntime = "daemon_runtime"
)
// Broadcaster is the abstraction every realtime event producer should depend
@@ -38,5 +41,10 @@ type Broadcaster interface {
Broadcast(message []byte)
}
+// DaemonRuntimeDeliverer consumes daemon-runtime scoped relay frames.
+type DaemonRuntimeDeliverer interface {
+ DeliverDaemonRuntime(scopeID string, frame []byte, eventID string)
+}
+
// Compile-time assertion that *Hub continues to satisfy Broadcaster.
var _ Broadcaster = (*Hub)(nil)
diff --git a/server/internal/realtime/redis_relay.go b/server/internal/realtime/redis_relay.go
index 8faf728b0c..00a8cf8faa 100644
--- a/server/internal/realtime/redis_relay.go
+++ b/server/internal/realtime/redis_relay.go
@@ -106,12 +106,16 @@ func redisString(v any) string {
}
}
-func deliverEnvelope(hub *Hub, ev envelope) {
+func deliverEnvelope(hub *Hub, daemonRuntime DaemonRuntimeDeliverer, ev envelope) {
if ev.PayloadJSON == "" {
return
}
frame := injectEventID([]byte(ev.PayloadJSON), ev.EventID)
switch ev.Scope {
+ case ScopeDaemonRuntime:
+ if daemonRuntime != nil {
+ daemonRuntime.DeliverDaemonRuntime(ev.ScopeID, frame, ev.EventID)
+ }
case "global":
hub.fanoutAllDedup(frame, "", ev.EventID)
case ScopeUser:
@@ -134,6 +138,8 @@ type RedisRelay struct {
consumers map[scopeKey]*scopeConsumer
stopping bool
wg sync.WaitGroup
+
+ daemonRuntime DaemonRuntimeDeliverer
}
type scopeConsumer struct {
@@ -167,6 +173,10 @@ func NewRedisRelayWithClients(hub *Hub, writeRDB, readRDB *redis.Client) *RedisR
// NodeID returns this relay's randomly-assigned node identifier.
func (r *RedisRelay) NodeID() string { return r.nodeID }
+func (r *RedisRelay) SetDaemonRuntimeDeliverer(d DaemonRuntimeDeliverer) {
+ r.daemonRuntime = d
+}
+
// Wait blocks until all relay-owned goroutines have exited after the Start
// context is canceled.
func (r *RedisRelay) Wait() {
@@ -394,7 +404,7 @@ func (r *RedisRelay) deliverMessage(scopeType, scopeID string, msg redis.XMessag
if ev.ScopeID == "" {
ev.ScopeID = scopeID
}
- deliverEnvelope(r.hub, ev)
+ deliverEnvelope(r.hub, r.daemonRuntime, ev)
}
// fanoutUser is implemented in hub.go.
diff --git a/server/internal/realtime/relay_lifecycle.go b/server/internal/realtime/relay_lifecycle.go
index ddd742d21a..eda9bc9456 100644
--- a/server/internal/realtime/relay_lifecycle.go
+++ b/server/internal/realtime/relay_lifecycle.go
@@ -36,6 +36,15 @@ func (r *MirroredRelay) NodeID() string {
return r.primary.NodeID()
}
+func (r *MirroredRelay) SetDaemonRuntimeDeliverer(d DaemonRuntimeDeliverer) {
+ if setter, ok := r.primary.(interface{ SetDaemonRuntimeDeliverer(DaemonRuntimeDeliverer) }); ok {
+ setter.SetDaemonRuntimeDeliverer(d)
+ }
+ if setter, ok := r.mirror.(interface{ SetDaemonRuntimeDeliverer(DaemonRuntimeDeliverer) }); ok {
+ setter.SetDaemonRuntimeDeliverer(d)
+ }
+}
+
func (r *MirroredRelay) Start(ctx context.Context) {
r.primary.Start(ctx)
r.mirror.Start(ctx)
@@ -74,6 +83,9 @@ func (r *MirroredRelay) Broadcast(message []byte) {
func (r *MirroredRelay) PublishWithID(scopeType, scopeID, exclude string, frame []byte, id string) error {
primaryErr := r.primary.PublishWithID(scopeType, scopeID, exclude, frame, id)
+ if scopeType == ScopeDaemonRuntime {
+ return primaryErr
+ }
mirrorErr := r.mirror.PublishWithID(scopeType, scopeID, exclude, frame, id)
if primaryErr != nil {
diff --git a/server/internal/realtime/relay_lifecycle_test.go b/server/internal/realtime/relay_lifecycle_test.go
index 8c035a828d..8e38fa008b 100644
--- a/server/internal/realtime/relay_lifecycle_test.go
+++ b/server/internal/realtime/relay_lifecycle_test.go
@@ -50,6 +50,23 @@ func TestMirroredRelayRecordsDivergenceWhenOneBackendFails(t *testing.T) {
}
}
+func TestMirroredRelayDoesNotMirrorDaemonRuntimeEvents(t *testing.T) {
+ primary := &recordingManagedRelay{nodeID: "primary"}
+ mirror := &recordingManagedRelay{nodeID: "mirror"}
+ relay := NewMirroredRelay(primary, mirror)
+
+ if err := relay.PublishWithID(ScopeDaemonRuntime, "task-1", "", []byte(`{"type":"daemon:task_available"}`), "event-1"); err != nil {
+ t.Fatalf("PublishWithID: %v", err)
+ }
+
+ if len(primary.calls) != 1 {
+ t.Fatalf("expected primary publish call, got %d", len(primary.calls))
+ }
+ if len(mirror.calls) != 0 {
+ t.Fatalf("expected daemon runtime event not to hit mirror, got %d calls", len(mirror.calls))
+ }
+}
+
type relayPublishCall struct {
scopeType string
scopeID string
diff --git a/server/internal/realtime/sharded_stream_relay.go b/server/internal/realtime/sharded_stream_relay.go
index 41d1c4e28f..04c3da722c 100644
--- a/server/internal/realtime/sharded_stream_relay.go
+++ b/server/internal/realtime/sharded_stream_relay.go
@@ -76,6 +76,8 @@ type ShardedStreamRelay struct {
mu sync.Mutex
stopping bool
wg sync.WaitGroup
+
+ daemonRuntime DaemonRuntimeDeliverer
}
func NewShardedStreamRelay(hub *Hub, writeRDB, readRDB *redis.Client, config ShardedStreamRelayConfig) *ShardedStreamRelay {
@@ -93,6 +95,10 @@ func NewShardedStreamRelay(hub *Hub, writeRDB, readRDB *redis.Client, config Sha
func (r *ShardedStreamRelay) NodeID() string { return r.nodeID }
+func (r *ShardedStreamRelay) SetDaemonRuntimeDeliverer(d DaemonRuntimeDeliverer) {
+ r.daemonRuntime = d
+}
+
func (r *ShardedStreamRelay) Start(ctx context.Context) {
M.NodeID.Store(r.nodeID)
if err := r.writeRDB.Ping(ctx).Err(); err != nil {
@@ -233,7 +239,7 @@ func (r *ShardedStreamRelay) deliverMessage(msg redis.XMessage) {
if !ok || ev.Scope == "" || ev.ScopeID == "" {
return
}
- deliverEnvelope(r.hub, ev)
+ deliverEnvelope(r.hub, r.daemonRuntime, ev)
}
func (r *ShardedStreamRelay) heartbeatLoop(ctx context.Context) {
diff --git a/server/internal/service/task.go b/server/internal/service/task.go
index ef44cdeb23..4ce05812b7 100644
--- a/server/internal/service/task.go
+++ b/server/internal/service/task.go
@@ -26,10 +26,19 @@ type TaskService struct {
TxStarter TxStarter
Hub *realtime.Hub
Bus *events.Bus
+ Wakeup TaskWakeupNotifier
}
-func NewTaskService(q *db.Queries, tx TxStarter, hub *realtime.Hub, bus *events.Bus) *TaskService {
- return &TaskService{Queries: q, TxStarter: tx, Hub: hub, Bus: bus}
+type TaskWakeupNotifier interface {
+ NotifyTaskAvailable(runtimeID, taskID string)
+}
+
+func NewTaskService(q *db.Queries, tx TxStarter, hub *realtime.Hub, bus *events.Bus, wakeups ...TaskWakeupNotifier) *TaskService {
+ var wakeup TaskWakeupNotifier
+ if len(wakeups) > 0 {
+ wakeup = wakeups[0]
+ }
+ return &TaskService{Queries: q, TxStarter: tx, Hub: hub, Bus: bus, Wakeup: wakeup}
}
// EnqueueTaskForIssue creates a queued task for an agent-assigned issue.
@@ -73,6 +82,7 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
}
slog.Info("task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(issue.AssigneeID))
+ s.notifyTaskAvailable(task)
return task, nil
}
@@ -107,6 +117,7 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
}
slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
+ s.notifyTaskAvailable(task)
return task, nil
}
@@ -137,6 +148,7 @@ func (s *TaskService) EnqueueChatTask(ctx context.Context, chatSession db.ChatSe
}
slog.Info("chat task enqueued", "task_id", util.UUIDToString(task.ID), "chat_session_id", util.UUIDToString(chatSession.ID), "agent_id", util.UUIDToString(chatSession.AgentID))
+ s.notifyTaskAvailable(task)
return task, nil
}
@@ -182,6 +194,24 @@ func (s *TaskService) CancelTasksForAgent(ctx context.Context, agentID pgtype.UU
return cancelled, nil
}
+// CancelTasksByTriggerComment cancels active tasks whose trigger is the given
+// comment. Called from DeleteComment so an agent does not run with the
+// now-deleted content already embedded in its prompt. Must be invoked BEFORE
+// the comment row is deleted because the FK ON DELETE SET NULL would
+// otherwise nullify trigger_comment_id and we'd lose the ability to find
+// the affected tasks.
+func (s *TaskService) CancelTasksByTriggerComment(ctx context.Context, commentID pgtype.UUID) error {
+ cancelled, err := s.Queries.CancelAgentTasksByTriggerComment(ctx, commentID)
+ if err != nil {
+ return err
+ }
+ for _, t := range cancelled {
+ s.ReconcileAgentStatus(ctx, t.AgentID)
+ s.broadcastTaskEvent(ctx, protocol.EventTaskCancelled, t)
+ }
+ return nil
+}
+
// CancelTask cancels a single task by ID. It broadcasts a task:cancelled event
// so frontends can update immediately.
func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) {
@@ -213,7 +243,7 @@ func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.A
func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.AgentTaskQueue, error) {
start := time.Now()
var (
- outcome = "unknown"
+ outcome = "unknown"
getAgentMs, countRunningMs, claimAgentMs, updateStatusMs, dispatchMs int64
)
defer func() {
@@ -256,10 +286,10 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A
slog.Info("task claimed", "task_id", util.UUIDToString(task.ID), "agent_id", util.UUIDToString(agentID))
- // Update agent status to working. Hits the DB and may publish events,
- // so it must be inside the timed window.
+ // Refresh agent status from active tasks. This avoids a stale unconditional
+ // working write racing after a just-cancelled claim.
t0 = time.Now()
- s.updateAgentStatus(ctx, agentID, "working")
+ s.ReconcileAgentStatus(ctx, agentID)
updateStatusMs = time.Since(t0).Milliseconds()
// Broadcast task:dispatch. ResolveTaskWorkspaceID inside this path can
@@ -278,10 +308,10 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A
func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype.UUID) (*db.AgentTaskQueue, error) {
start := time.Now()
var (
- outcome = "no_task"
- listMs, loopMs int64
- listCount, tried int
- claimedFlag bool
+ outcome = "no_task"
+ listMs, loopMs int64
+ listCount, tried int
+ claimedFlag bool
)
defer func() {
totalMs := time.Since(start).Milliseconds()
@@ -648,6 +678,7 @@ func (s *TaskService) MaybeRetryFailedTask(ctx context.Context, parent db.AgentT
"attempt", child.Attempt,
"max_attempts", child.MaxAttempts,
)
+ s.notifyTaskAvailable(child)
s.broadcastTaskEvent(ctx, protocol.EventTaskDispatch, child)
return &child, nil
}
@@ -827,18 +858,14 @@ func (s *TaskService) ReportProgress(ctx context.Context, taskID string, workspa
})
}
-// ReconcileAgentStatus checks running task count and sets agent status accordingly.
+// ReconcileAgentStatus refreshes agent status from the current active task set.
func (s *TaskService) ReconcileAgentStatus(ctx context.Context, agentID pgtype.UUID) {
- running, err := s.Queries.CountRunningTasks(ctx, agentID)
+ agent, err := s.Queries.RefreshAgentStatusFromTasks(ctx, agentID)
if err != nil {
return
}
- newStatus := "idle"
- if running > 0 {
- newStatus = "working"
- }
- slog.Debug("agent status reconciled", "agent_id", util.UUIDToString(agentID), "status", newStatus, "running_tasks", running)
- s.updateAgentStatus(ctx, agentID, newStatus)
+ slog.Debug("agent status reconciled", "agent_id", util.UUIDToString(agentID), "status", agent.Status)
+ s.publishAgentStatus(agent)
}
func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID, status string) {
@@ -849,6 +876,10 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID
if err != nil {
return
}
+ s.publishAgentStatus(agent)
+}
+
+func (s *TaskService) publishAgentStatus(agent db.Agent) {
s.Bus.Publish(events.Event{
Type: protocol.EventAgentStatus,
WorkspaceID: util.UUIDToString(agent.WorkspaceID),
@@ -905,6 +936,13 @@ func priorityToInt(p string) int32 {
}
}
+func (s *TaskService) notifyTaskAvailable(task db.AgentTaskQueue) {
+ if s.Wakeup == nil || !task.RuntimeID.Valid {
+ return
+ }
+ s.Wakeup.NotifyTaskAvailable(util.UUIDToString(task.RuntimeID), util.UUIDToString(task.ID))
+}
+
func (s *TaskService) broadcastTaskDispatch(ctx context.Context, task db.AgentTaskQueue) {
var payload map[string]any
if task.Context != nil {
diff --git a/server/internal/util/pgx.go b/server/internal/util/pgx.go
index 442576329d..683d3d0815 100644
--- a/server/internal/util/pgx.go
+++ b/server/internal/util/pgx.go
@@ -2,14 +2,39 @@ package util
import (
"encoding/hex"
+ "fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
-func ParseUUID(s string) pgtype.UUID {
+// ParseUUID parses s into a pgtype.UUID. Invalid input returns an error
+// instead of a zero-valued UUID — silently dropping bad input has caused
+// data-loss bugs (e.g. DELETE matching no rows, returning 204 success).
+//
+// Use this at any boundary where s comes from user input (URL params,
+// request bodies, headers) and pair it with a 4xx response on error.
+// For trusted, already-validated UUID strings (sqlc round-trips, fixtures),
+// use MustParseUUID instead.
+func ParseUUID(s string) (pgtype.UUID, error) {
var u pgtype.UUID
- _ = u.Scan(s)
+ if err := u.Scan(s); err != nil {
+ return u, fmt.Errorf("invalid UUID %q: %w", s, err)
+ }
+ if !u.Valid {
+ return u, fmt.Errorf("invalid UUID: %q", s)
+ }
+ return u, nil
+}
+
+// MustParseUUID parses s into a pgtype.UUID and panics on invalid input.
+// Reserve for trusted callers (already-validated round-trips, test fixtures).
+// At a request boundary, use ParseUUID and surface a 4xx instead.
+func MustParseUUID(s string) pgtype.UUID {
+ u, err := ParseUUID(s)
+ if err != nil {
+ panic(err)
+ }
return u
}
diff --git a/server/internal/util/pgx_test.go b/server/internal/util/pgx_test.go
new file mode 100644
index 0000000000..e1298f3498
--- /dev/null
+++ b/server/internal/util/pgx_test.go
@@ -0,0 +1,47 @@
+package util
+
+import "testing"
+
+func TestParseUUID_Valid(t *testing.T) {
+ u, err := ParseUUID("550e8400-e29b-41d4-a716-446655440000")
+ if err != nil {
+ t.Fatalf("expected nil error, got %v", err)
+ }
+ if !u.Valid {
+ t.Fatalf("expected u.Valid = true")
+ }
+}
+
+func TestParseUUID_InvalidReturnsError(t *testing.T) {
+ cases := []string{"", "not-a-uuid", "MUL-123", "12345"}
+ for _, s := range cases {
+ t.Run(s, func(t *testing.T) {
+ u, err := ParseUUID(s)
+ if err == nil {
+ t.Fatalf("expected error for %q, got nil (u.Valid=%v)", s, u.Valid)
+ }
+ if u.Valid {
+ // Critical invariant: invalid input must NOT yield a valid UUID.
+ // Returning a valid zero-UUID was the root cause of #1661.
+ t.Fatalf("expected u.Valid = false for %q, got true", s)
+ }
+ })
+ }
+}
+
+func TestMustParseUUID_PanicsOnInvalid(t *testing.T) {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatalf("expected MustParseUUID to panic on invalid input")
+ }
+ }()
+ MustParseUUID("not-a-uuid")
+}
+
+func TestMustParseUUID_RoundTrip(t *testing.T) {
+ const s = "550e8400-e29b-41d4-a716-446655440000"
+ u := MustParseUUID(s)
+ if got := UUIDToString(u); got != s {
+ t.Fatalf("round-trip mismatch: got %q want %q", got, s)
+ }
+}
diff --git a/server/migrations/059_label_timestamps.down.sql b/server/migrations/059_label_timestamps.down.sql
new file mode 100644
index 0000000000..2c98ebe5cc
--- /dev/null
+++ b/server/migrations/059_label_timestamps.down.sql
@@ -0,0 +1,4 @@
+DROP INDEX IF EXISTS issue_label_workspace_name_lower_idx;
+ALTER TABLE issue_label
+ DROP COLUMN IF EXISTS updated_at,
+ DROP COLUMN IF EXISTS created_at;
diff --git a/server/migrations/059_label_timestamps.up.sql b/server/migrations/059_label_timestamps.up.sql
new file mode 100644
index 0000000000..5631f4cf76
--- /dev/null
+++ b/server/migrations/059_label_timestamps.up.sql
@@ -0,0 +1,29 @@
+-- Add timestamp columns to issue_label so labels track their own lifecycle.
+-- The table was scaffolded in 001_init.up.sql but never wired up to any code
+-- path; timestamps are added here as a precondition for the new CRUD handlers,
+-- CLI, and UI (see #1191).
+
+ALTER TABLE issue_label
+ ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
+
+-- Dedupe case-insensitive collisions before the unique index is created.
+-- Self-hosted deployments may have `Bug` + `bug` pairs from manual poking at
+-- the table, which would otherwise abort this migration when the unique index
+-- is added below. Keep the oldest row intact (smallest id, by lexicographic
+-- UUID order — effectively earliest-inserted since ids are gen_random_uuid()),
+-- rename every later duplicate by appending a short UUID suffix. Visible only
+-- on the rare installs that had duplicates; a no-op otherwise.
+UPDATE issue_label AS il
+SET name = il.name || ' (' || substring(il.id::text, 1, 8) || ')'
+WHERE EXISTS (
+ SELECT 1 FROM issue_label il2
+ WHERE il2.workspace_id = il.workspace_id
+ AND LOWER(il2.name) = LOWER(il.name)
+ AND il2.id < il.id
+);
+
+-- Workspace-scoped uniqueness on label name. Case-insensitive to avoid
+-- "Bug" / "bug" drift that would confuse users in the picker UI.
+CREATE UNIQUE INDEX IF NOT EXISTS issue_label_workspace_name_lower_idx
+ ON issue_label (workspace_id, LOWER(name));
diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go
index 842e616f22..246fe14943 100644
--- a/server/pkg/agent/agent.go
+++ b/server/pkg/agent/agent.go
@@ -1,6 +1,6 @@
// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, Codex, Copilot, OpenCode, OpenClaw, Hermes,
-// Gemini, Pi, Cursor, Kimi). It mirrors the happy-cli AgentBackend
+// Gemini, Pi, Cursor, Kimi, Kiro). It mirrors the happy-cli AgentBackend
// pattern, translated to idiomatic Go.
package agent
@@ -22,14 +22,15 @@ type Backend interface {
// ExecOptions configures a single execution.
type ExecOptions struct {
- Cwd string
- Model string
- SystemPrompt string
- MaxTurns int
- Timeout time.Duration
- ResumeSessionID string // if non-empty, resume a previous agent session
- CustomArgs []string // additional CLI arguments appended to the agent command
- McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config
+ Cwd string
+ Model string
+ SystemPrompt string
+ MaxTurns int
+ Timeout time.Duration
+ SemanticInactivityTimeout time.Duration
+ ResumeSessionID string // if non-empty, resume a previous agent session
+ CustomArgs []string // additional CLI arguments appended to the agent command
+ McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config
}
// Session represents a running agent execution.
@@ -87,13 +88,13 @@ type Result struct {
// Config configures a Backend instance.
type Config struct {
- ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi)
+ ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro-cli)
Env map[string]string // extra environment variables
Logger *slog.Logger
}
// New creates a Backend for the given agent type.
-// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi".
+// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
@@ -120,8 +121,10 @@ func New(agentType string, cfg Config) (Backend, error) {
return &cursorBackend{cfg: cfg}, nil
case "kimi":
return &kimiBackend{cfg: cfg}, nil
+ case "kiro":
+ return &kiroBackend{cfg: cfg}, nil
default:
- return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi)", agentType)
+ return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro)", agentType)
}
}
@@ -147,6 +150,7 @@ var launchHeaders = map[string]string{
"opencode": "opencode run (json)",
"pi": "pi (json mode)",
"kimi": "kimi acp",
+ "kiro": "kiro-cli acp",
}
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
diff --git a/server/pkg/agent/agent_test.go b/server/pkg/agent/agent_test.go
index ce28fa1eee..8324b35a7f 100644
--- a/server/pkg/agent/agent_test.go
+++ b/server/pkg/agent/agent_test.go
@@ -72,7 +72,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
// entry to launchHeaders in agent.go and extend this list.
supported := []string{
"claude", "codex", "copilot", "cursor", "gemini",
- "hermes", "kimi", "openclaw", "opencode", "pi",
+ "hermes", "kimi", "kiro", "openclaw", "opencode", "pi",
}
for _, t_ := range supported {
if header := LaunchHeader(t_); header == "" {
diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go
index d430bd89ea..55165b101e 100644
--- a/server/pkg/agent/codex.go
+++ b/server/pkg/agent/codex.go
@@ -25,7 +25,10 @@ var codexBlockedArgs = map[string]blockedArgMode{
// user supplied a custom_args flag that the `app-server` subcommand
// rejects). Kept as its own constant so bumping codex independently of
// other agents stays easy if codex starts shipping longer failure traces.
-const codexStderrTailBytes = 2048
+const (
+ codexStderrTailBytes = 2048
+ defaultCodexSemanticInactivityTimeout = 10 * time.Minute
+)
// codexBackend implements Backend by spawning `codex app-server --listen stdio://`
// and communicating via JSON-RPC 2.0 over stdin/stdout.
@@ -46,6 +49,10 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
if timeout == 0 {
timeout = 20 * time.Minute
}
+ semanticInactivityTimeout := opts.SemanticInactivityTimeout
+ if semanticInactivityTimeout == 0 {
+ semanticInactivityTimeout = defaultCodexSemanticInactivityTimeout
+ }
runCtx, cancel := context.WithTimeout(ctx, timeout)
codexArgs := append([]string{"app-server", "--listen", "stdio://"}, filterCustomArgs(opts.CustomArgs, codexBlockedArgs, b.cfg.Logger)...)
@@ -79,6 +86,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
+ semanticActivityCh := make(chan string, 256)
var outputMu sync.Mutex
var output strings.Builder
@@ -93,12 +101,18 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
pending: make(map[int]*pendingRPC),
notificationProtocol: "unknown",
onMessage: func(msg Message) {
+ logCodexAgentMessage(b.cfg.Logger, msg)
if msg.Type == MessageText {
outputMu.Lock()
output.WriteString(msg.Content)
outputMu.Unlock()
}
trySend(msgCh, msg)
+ trySendString(semanticActivityCh, describeCodexSemanticActivity(msg))
+ },
+ onSemanticActivity: func(description string) {
+ b.cfg.Logger.Debug("codex semantic activity observed", "activity", description)
+ trySendString(semanticActivityCh, description)
},
onTurnDone: func(aborted bool) {
select {
@@ -207,26 +221,51 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
return
}
- // Wait for turn completion or context cancellation
- select {
- case aborted := <-turnDone:
- switch {
- case aborted:
- finalStatus = "aborted"
- finalError = "turn was aborted"
- default:
- if errMsg := c.getTurnError(); errMsg != "" {
- finalStatus = "failed"
- finalError = errMsg
+ lastSemanticActivity := time.Now()
+ lastSemanticActivityDescription := "turn/start"
+ semanticTimer := time.NewTimer(semanticInactivityTimeout)
+ defer semanticTimer.Stop()
+
+ waitingForTurn := true
+ for waitingForTurn {
+ select {
+ case aborted := <-turnDone:
+ waitingForTurn = false
+ switch {
+ case aborted:
+ finalStatus = "aborted"
+ finalError = "turn was aborted"
+ default:
+ if errMsg := c.getTurnError(); errMsg != "" {
+ finalStatus = "failed"
+ finalError = errMsg
+ }
}
- }
- case <-runCtx.Done():
- if runCtx.Err() == context.DeadlineExceeded {
+ case activity := <-semanticActivityCh:
+ lastSemanticActivity = time.Now()
+ lastSemanticActivityDescription = activity
+ resetTimer(semanticTimer, semanticInactivityTimeout)
+ case <-semanticTimer.C:
+ waitingForTurn = false
finalStatus = "timeout"
- finalError = fmt.Sprintf("codex timed out after %s", timeout)
- } else {
- finalStatus = "aborted"
- finalError = "execution cancelled"
+ finalError = fmt.Sprintf("codex semantic inactivity timeout after %s without agent progress (last activity: %s)", semanticInactivityTimeout, lastSemanticActivityDescription)
+ b.cfg.Logger.Warn("codex semantic inactivity timeout",
+ "pid", cmd.Process.Pid,
+ "thread_id", threadID,
+ "turn_id", c.turnID,
+ "timeout", semanticInactivityTimeout.String(),
+ "last_activity", lastSemanticActivityDescription,
+ "idle_for", time.Since(lastSemanticActivity).Round(time.Millisecond).String(),
+ )
+ case <-runCtx.Done():
+ waitingForTurn = false
+ if runCtx.Err() == context.DeadlineExceeded {
+ finalStatus = "timeout"
+ finalError = fmt.Sprintf("codex timed out after %s", timeout)
+ } else {
+ finalStatus = "aborted"
+ finalError = "execution cancelled"
+ }
}
}
@@ -337,18 +376,68 @@ func (c *codexClient) startOrResumeThread(ctx context.Context, opts ExecOptions,
return threadID, false, nil
}
+func resetTimer(timer *time.Timer, d time.Duration) {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(d)
+}
+
+func trySendString(ch chan<- string, value string) {
+ select {
+ case ch <- value:
+ default:
+ }
+}
+
+func logCodexAgentMessage(logger *slog.Logger, msg Message) {
+ if logger == nil {
+ return
+ }
+ attrs := []any{
+ "type", string(msg.Type),
+ "tool", msg.Tool,
+ "call_id", msg.CallID,
+ "status", msg.Status,
+ "content_len", len(msg.Content),
+ "output_len", len(msg.Output),
+ }
+ logger.Info("codex agent message received", attrs...)
+ if msg.Type == MessageToolResult {
+ logger.Info("codex tool_result observed", "tool", msg.Tool, "call_id", msg.CallID, "output_len", len(msg.Output))
+ }
+}
+
+func describeCodexSemanticActivity(msg Message) string {
+ switch msg.Type {
+ case MessageToolUse, MessageToolResult:
+ if msg.Tool != "" {
+ return fmt.Sprintf("%s:%s", msg.Type, msg.Tool)
+ }
+ case MessageStatus:
+ if msg.Status != "" {
+ return fmt.Sprintf("%s:%s", msg.Type, msg.Status)
+ }
+ }
+ return string(msg.Type)
+}
+
// ── codexClient: JSON-RPC 2.0 transport ──
type codexClient struct {
- cfg Config
- stdin interface{ Write([]byte) (int, error) }
- mu sync.Mutex
- nextID int
- pending map[int]*pendingRPC
- threadID string
- turnID string
- onMessage func(Message)
- onTurnDone func(aborted bool)
+ cfg Config
+ stdin interface{ Write([]byte) (int, error) }
+ mu sync.Mutex
+ nextID int
+ pending map[int]*pendingRPC
+ threadID string
+ turnID string
+ onMessage func(Message)
+ onSemanticActivity func(description string)
+ onTurnDone func(aborted bool)
notificationProtocol string // "unknown", "legacy", "raw"
turnStarted bool
@@ -416,6 +505,13 @@ func (c *codexClient) request(ctx context.Context, method string, params any) (j
c.mu.Unlock()
return nil, fmt.Errorf("write %s: %w", method, err)
}
+ if method == "turn/start" {
+ threadID := ""
+ if paramMap, ok := params.(map[string]any); ok {
+ threadID, _ = paramMap["threadId"].(string)
+ }
+ c.cfg.Logger.Info("codex turn/start sent", "request_id", id, "thread_id", threadID)
+ }
select {
case res := <-pr.ch:
@@ -666,6 +762,8 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any
case "turn/completed":
turnID := extractNestedString(params, "turn", "id")
status := extractNestedString(params, "turn", "status")
+ threadID, _ := params["threadId"].(string)
+ c.cfg.Logger.Info("codex turn/completed received", "thread_id", threadID, "turn_id", turnID, "status", status)
aborted := status == "cancelled" || status == "canceled" ||
status == "aborted" || status == "interrupted"
@@ -730,13 +828,15 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any
}
func (c *codexClient) handleItemNotification(method string, params map[string]any) {
- item, ok := params["item"].(map[string]any)
- if !ok {
- return
- }
-
+ item, _ := params["item"].(map[string]any)
itemType, _ := item["type"].(string)
itemID, _ := item["id"].(string)
+ if isCodexItemProgressActivity(method) && c.onSemanticActivity != nil {
+ c.onSemanticActivity(describeCodexItemProgressActivity(method, itemType, itemID))
+ }
+ if item == nil {
+ return
+ }
switch {
case method == "item/started" && itemType == "commandExecution":
@@ -793,6 +893,28 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an
}
}
+func isCodexItemProgressActivity(method string) bool {
+ switch method {
+ case "item/agentMessage/delta",
+ "item/commandExecution/outputDelta",
+ "item/fileChange/outputDelta",
+ "item/mcpToolCall/progress":
+ return true
+ default:
+ return false
+ }
+}
+
+func describeCodexItemProgressActivity(method, itemType, itemID string) string {
+ if itemType == "" {
+ itemType = "unknown"
+ }
+ if itemID == "" {
+ return fmt.Sprintf("%s:%s", method, itemType)
+ }
+ return fmt.Sprintf("%s:%s:%s", method, itemType, itemID)
+}
+
// extractUsageFromMap extracts token usage from a map that may contain
// "usage", "token_usage", or "tokens" fields. Handles various Codex formats.
func (c *codexClient) extractUsageFromMap(data map[string]any) {
diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go
index 686889df90..9ba6a25516 100644
--- a/server/pkg/agent/codex_test.go
+++ b/server/pkg/agent/codex_test.go
@@ -1011,6 +1011,175 @@ func TestCodexExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) {
}
}
+func TestCodexExecuteTimesOutWhenTurnStopsAfterToolResult(t *testing.T) {
+ t.Parallel()
+ if runtime.GOOS == "windows" {
+ t.Skip("shell-script fixture is POSIX-only")
+ }
+
+ fakePath := writeFakeCodexAppServer(t, ""+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+
+ `read line`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-stale"}}}'`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-stale","turn":{"id":"turn-stale"}}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-stale","item":{"type":"commandExecution","id":"cmd-1","command":"git status"}}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-stale","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"clean"}}}'`+"\n"+
+ `sleep 5`+"\n")
+
+ result := executeFakeCodex(t, fakePath, ExecOptions{
+ Timeout: 5 * time.Second,
+ SemanticInactivityTimeout: 100 * time.Millisecond,
+ })
+ if result.Status != "timeout" {
+ t.Fatalf("expected timeout, got status=%q error=%q", result.Status, result.Error)
+ }
+ if !strings.Contains(result.Error, "semantic inactivity") {
+ t.Fatalf("expected semantic inactivity error, got %q", result.Error)
+ }
+ if result.SessionID != "thr-stale" {
+ t.Fatalf("expected session id to be preserved, got %q", result.SessionID)
+ }
+}
+
+func TestCodexExecuteSemanticInactivityAllowsContinuousMessages(t *testing.T) {
+ t.Parallel()
+ if runtime.GOOS == "windows" {
+ t.Skip("shell-script fixture is POSIX-only")
+ }
+
+ fakePath := writeFakeCodexAppServer(t, ""+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+
+ `read line`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-progress"}}}'`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-progress","turn":{"id":"turn-progress"}}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-progress","item":{"type":"agentMessage","id":"msg-1","text":"still working"}}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-progress","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"ok"}}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-progress","turn":{"id":"turn-progress","status":"completed"}}}'`+"\n")
+
+ result := executeFakeCodex(t, fakePath, ExecOptions{
+ Timeout: 5 * time.Second,
+ SemanticInactivityTimeout: 90 * time.Millisecond,
+ })
+ if result.Status != "completed" {
+ t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error)
+ }
+ if !strings.Contains(result.Output, "still working") {
+ t.Fatalf("expected streamed text in output, got %q", result.Output)
+ }
+}
+
+func TestCodexExecuteSemanticInactivityAllowsContinuousDeltaProgress(t *testing.T) {
+ t.Parallel()
+ if runtime.GOOS == "windows" {
+ t.Skip("shell-script fixture is POSIX-only")
+ }
+
+ fakePath := writeFakeCodexAppServer(t, ""+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+
+ `read line`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-delta"}}}'`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-delta","turn":{"id":"turn-delta"}}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/commandExecution/outputDelta","params":{"threadId":"thr-delta","item":{"type":"commandExecution","id":"cmd-1"},"delta":"line 1\n"}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thr-delta","item":{"type":"agentMessage","id":"msg-1"},"delta":"thinking"}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"threadId":"thr-delta","item":{"type":"fileChange","id":"patch-1"},"delta":"patched"}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/mcpToolCall/progress","params":{"threadId":"thr-delta","item":{"type":"mcpToolCall","id":"mcp-1"},"progress":{"message":"still running"}}}'`+"\n"+
+ `sleep 0.05`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-delta","turn":{"id":"turn-delta","status":"completed"}}}'`+"\n")
+
+ result := executeFakeCodex(t, fakePath, ExecOptions{
+ Timeout: 5 * time.Second,
+ SemanticInactivityTimeout: 150 * time.Millisecond,
+ })
+ if result.Status != "completed" {
+ t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error)
+ }
+}
+
+func TestCodexExecuteSemanticInactivityDoesNotAffectNormalTurnCompletion(t *testing.T) {
+ t.Parallel()
+ if runtime.GOOS == "windows" {
+ t.Skip("shell-script fixture is POSIX-only")
+ }
+
+ fakePath := writeFakeCodexAppServer(t, ""+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+
+ `read line`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-normal"}}}'`+"\n"+
+ `read line`+"\n"+
+ `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-normal","turn":{"id":"turn-normal"}}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-normal","item":{"type":"agentMessage","id":"msg-1","text":"Done"}}}'`+"\n"+
+ `echo '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-normal","turn":{"id":"turn-normal","status":"completed"}}}'`+"\n")
+
+ result := executeFakeCodex(t, fakePath, ExecOptions{
+ Timeout: 5 * time.Second,
+ SemanticInactivityTimeout: 100 * time.Millisecond,
+ })
+ if result.Status != "completed" {
+ t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error)
+ }
+ if result.Output != "Done" {
+ t.Fatalf("expected output Done, got %q", result.Output)
+ }
+}
+
+func writeFakeCodexAppServer(t *testing.T, body string) string {
+ t.Helper()
+ fakePath := filepath.Join(t.TempDir(), "codex")
+ script := "#!/bin/sh\n" + body
+ writeTestExecutable(t, fakePath, []byte(script))
+ return fakePath
+}
+
+func executeFakeCodex(t *testing.T, fakePath string, opts ExecOptions) Result {
+ t.Helper()
+ backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()})
+ if err != nil {
+ t.Fatalf("new codex backend: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ session, err := backend.Execute(ctx, "prompt", opts)
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+ select {
+ case result, ok := <-session.Result:
+ if !ok {
+ t.Fatal("result channel closed without a value")
+ }
+ return result
+ case <-time.After(10 * time.Second):
+ t.Fatal("timeout waiting for result")
+ return Result{}
+ }
+}
+
func TestWithAgentStderrAppendsHint(t *testing.T) {
t.Parallel()
diff --git a/server/pkg/agent/exec_fixture_windows_test.go b/server/pkg/agent/exec_fixture_windows_test.go
new file mode 100644
index 0000000000..37e3d6d2a5
--- /dev/null
+++ b/server/pkg/agent/exec_fixture_windows_test.go
@@ -0,0 +1,24 @@
+//go:build windows
+
+package agent
+
+import (
+ "os"
+ "testing"
+)
+
+// writeTestExecutable is the Windows counterpart to the //go:build unix
+// implementation in exec_fixture_unix_test.go. ETXTBSY is a Linux/Unix
+// fork-exec race; Windows doesn't have that pathology, so a plain
+// os.WriteFile is sufficient.
+//
+// The helper is referenced by claude_test.go / codex_test.go /
+// kimi_test.go, so the absence of a Windows impl made
+// `go test ./pkg/agent` fail to build on Windows. Lifted from #1719
+// (Codex) with attribution.
+func writeTestExecutable(tb testing.TB, path string, content []byte) {
+ tb.Helper()
+ if err := os.WriteFile(path, content, 0o755); err != nil {
+ tb.Fatalf("write test executable %s: %v", path, err)
+ }
+}
diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go
index cc60cf55c5..b25409b556 100644
--- a/server/pkg/agent/hermes.go
+++ b/server/pkg/agent/hermes.go
@@ -343,6 +343,9 @@ type hermesClient struct {
sessionID string
onMessage func(Message)
onPromptDone func(hermesPromptResult)
+ // acceptNotification can drop ACP session updates before dispatching to
+ // handlers that mutate client state such as usage or pending tool calls.
+ acceptNotification func(updateType string) bool
// pendingTools buffers the args for tool calls whose input streams in
// across multiple ACP tool_call_update messages (kimi does this —
@@ -587,7 +590,7 @@ func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) {
var method string
_ = json.Unmarshal(raw["method"], &method)
- if method != "session/update" {
+ if method != "session/update" && method != "session/notification" {
return
}
@@ -602,23 +605,69 @@ func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) {
return
}
- // Parse the update discriminator.
+ updateType, updateData := normalizeACPUpdate(params.Update)
+ if c.acceptNotification != nil && !c.acceptNotification(updateType) {
+ return
+ }
+
+ switch updateType {
+ case "agent_message_chunk":
+ c.handleAgentMessage(updateData)
+ case "agent_thought_chunk":
+ c.handleAgentThought(updateData)
+ case "tool_call":
+ c.handleToolCallStart(updateData)
+ case "tool_call_update":
+ c.handleToolCallUpdate(updateData)
+ case "usage_update":
+ c.handleUsageUpdate(updateData)
+ case "turn_end":
+ c.extractPromptResult(updateData)
+ }
+}
+
+func normalizeACPUpdate(data json.RawMessage) (string, json.RawMessage) {
var updateType struct {
SessionUpdate string `json:"sessionUpdate"`
+ Type string `json:"type"`
+ }
+ _ = json.Unmarshal(data, &updateType)
+ if updateType.SessionUpdate != "" {
+ return normalizeACPUpdateType(updateType.SessionUpdate), data
+ }
+ if updateType.Type != "" {
+ return normalizeACPUpdateType(updateType.Type), data
}
- _ = json.Unmarshal(params.Update, &updateType)
- switch updateType.SessionUpdate {
- case "agent_message_chunk":
- c.handleAgentMessage(params.Update)
- case "agent_thought_chunk":
- c.handleAgentThought(params.Update)
- case "tool_call":
- c.handleToolCallStart(params.Update)
- case "tool_call_update":
- c.handleToolCallUpdate(params.Update)
- case "usage_update":
- c.handleUsageUpdate(params.Update)
+ // Some ACP implementations serialize enum variants as an externally
+ // tagged object: {"agentMessageChunk": {"content": ...}}.
+ var wrapper map[string]json.RawMessage
+ if err := json.Unmarshal(data, &wrapper); err == nil && len(wrapper) == 1 {
+ for k, v := range wrapper {
+ return normalizeACPUpdateType(k), v
+ }
+ }
+
+ return "", data
+}
+
+func normalizeACPUpdateType(t string) string {
+ key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(t), "_", ""), "-", ""))
+ switch key {
+ case "agentmessagechunk":
+ return "agent_message_chunk"
+ case "agentthoughtchunk":
+ return "agent_thought_chunk"
+ case "toolcall":
+ return "tool_call"
+ case "toolcallupdate":
+ return "tool_call_update"
+ case "usageupdate":
+ return "usage_update"
+ case "turnend", "endturn":
+ return "turn_end"
+ default:
+ return ""
}
}
@@ -655,9 +704,12 @@ func (c *hermesClient) handleAgentThought(data json.RawMessage) {
func (c *hermesClient) handleToolCallStart(data json.RawMessage) {
var msg struct {
ToolCallID string `json:"toolCallId"`
+ Name string `json:"name"`
Title string `json:"title"`
Kind string `json:"kind"`
RawInput map[string]any `json:"rawInput"`
+ Input map[string]any `json:"input"`
+ Parameters map[string]any `json:"parameters"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &msg); err != nil {
@@ -665,15 +717,25 @@ func (c *hermesClient) handleToolCallStart(data json.RawMessage) {
}
toolName := hermesToolNameFromTitle(msg.Title, msg.Kind)
+ if toolName == "" {
+ toolName = msg.Name
+ }
+ rawInput := msg.RawInput
+ if rawInput == nil {
+ rawInput = msg.Input
+ }
+ if rawInput == nil {
+ rawInput = msg.Parameters
+ }
// Hermes pre-populates rawInput on the initial tool_call — emit
// MessageToolUse immediately so the UI can show the tool invocation
// live. Record the emission so handleToolCallUpdate doesn't re-emit
// on completion.
- if msg.RawInput != nil {
+ if rawInput != nil {
c.trackTool(msg.ToolCallID, &pendingToolCall{
toolName: toolName,
- input: msg.RawInput,
+ input: rawInput,
emitted: true,
})
if c.onMessage != nil {
@@ -681,7 +743,7 @@ func (c *hermesClient) handleToolCallStart(data json.RawMessage) {
Type: MessageToolUse,
Tool: toolName,
CallID: msg.ToolCallID,
- Input: msg.RawInput,
+ Input: rawInput,
})
}
return
@@ -702,16 +764,32 @@ func (c *hermesClient) handleToolCallUpdate(data json.RawMessage) {
var msg struct {
ToolCallID string `json:"toolCallId"`
Status string `json:"status"`
+ Name string `json:"name"`
Title string `json:"title"`
Kind string `json:"kind"`
RawInput map[string]any `json:"rawInput"`
+ Input map[string]any `json:"input"`
+ Parameters map[string]any `json:"parameters"`
RawOutput string `json:"rawOutput"`
+ Output string `json:"output"`
Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
+ rawInput := msg.RawInput
+ if rawInput == nil {
+ rawInput = msg.Input
+ }
+ if rawInput == nil {
+ rawInput = msg.Parameters
+ }
+ title := msg.Title
+ if title == "" {
+ title = msg.Name
+ }
+
// Mid-stream: only buffer updates. Kimi emits many of these per
// tool call, each carrying the cumulative args JSON so far.
if msg.Status != "completed" && msg.Status != "failed" {
@@ -727,9 +805,12 @@ func (c *hermesClient) handleToolCallUpdate(data json.RawMessage) {
// Completion: emit any deferred MessageToolUse first, then the result.
pending := c.takePendingTool(msg.ToolCallID)
- c.emitDeferredToolUse(pending, msg.ToolCallID, msg.Title, msg.Kind, msg.RawInput)
+ c.emitDeferredToolUse(pending, msg.ToolCallID, title, msg.Kind, rawInput)
output := msg.RawOutput
+ if output == "" {
+ output = msg.Output
+ }
if output == "" {
output = extractACPToolCallText(msg.Content)
}
@@ -961,7 +1042,7 @@ func (c *hermesClient) handleUsageUpdate(data json.RawMessage) {
// ── Helpers ──
// extractACPSessionID pulls `sessionId` out of a session/new or
-// session/resume response. Shared by all ACP backends (hermes, kimi,
+// session/resume response. Shared by all ACP backends (hermes, kimi, kiro,
// and anything else that follows the standard ACP schema).
func extractACPSessionID(result json.RawMessage) string {
var r struct {
diff --git a/server/pkg/agent/hermes_test.go b/server/pkg/agent/hermes_test.go
index 852b63b5f6..5476fd19ee 100644
--- a/server/pkg/agent/hermes_test.go
+++ b/server/pkg/agent/hermes_test.go
@@ -292,6 +292,28 @@ func TestHermesClientHandleAgentMessage(t *testing.T) {
}
}
+func TestHermesClientHandleSessionNotificationAgentMessage(t *testing.T) {
+ t.Parallel()
+
+ var got Message
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onMessage: func(msg Message) {
+ got = msg
+ },
+ }
+
+ line := `{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"Hello from Kiro"}}}}`
+ c.handleLine(line)
+
+ if got.Type != MessageText {
+ t.Errorf("type: got %v, want MessageText", got.Type)
+ }
+ if got.Content != "Hello from Kiro" {
+ t.Errorf("content: got %q, want %q", got.Content, "Hello from Kiro")
+ }
+}
+
func TestHermesClientHandleAgentThought(t *testing.T) {
t.Parallel()
@@ -342,6 +364,62 @@ func TestHermesClientHandleToolCallStart(t *testing.T) {
}
}
+func TestHermesClientHandleSessionNotificationToolCall(t *testing.T) {
+ t.Parallel()
+
+ var got []Message
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onMessage: func(msg Message) {
+ got = append(got, msg)
+ },
+ }
+
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"ToolCall","toolCallId":"tc-kiro","name":"Shell","status":"pending","parameters":{"command":"pwd"}}}}`)
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"ToolCallUpdate","toolCallId":"tc-kiro","status":"completed","name":"Shell","output":"/tmp/project\n"}}}`)
+
+ if len(got) != 2 {
+ t.Fatalf("expected [ToolUse, ToolResult], got %+v", got)
+ }
+ if got[0].Type != MessageToolUse {
+ t.Errorf("first message: got %v, want MessageToolUse", got[0].Type)
+ }
+ if got[0].Tool != "Shell" {
+ t.Errorf("first tool: got %q, want Shell", got[0].Tool)
+ }
+ if cmd, _ := got[0].Input["command"].(string); cmd != "pwd" {
+ t.Errorf("first input.command: got %v, want pwd", got[0].Input["command"])
+ }
+ if got[1].Type != MessageToolResult {
+ t.Errorf("second message: got %v, want MessageToolResult", got[1].Type)
+ }
+ if got[1].Output != "/tmp/project\n" {
+ t.Errorf("second output: got %q", got[1].Output)
+ }
+}
+
+func TestHermesClientHandleSessionNotificationTurnEnd(t *testing.T) {
+ t.Parallel()
+
+ var got hermesPromptResult
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onPromptDone: func(result hermesPromptResult) {
+ got = result
+ },
+ }
+
+ line := `{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"TurnEnd","stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":4,"cachedReadTokens":1}}}}`
+ c.handleLine(line)
+
+ if got.stopReason != "end_turn" {
+ t.Errorf("stopReason: got %q, want end_turn", got.stopReason)
+ }
+ if got.usage.InputTokens != 3 || got.usage.OutputTokens != 4 || got.usage.CacheReadTokens != 1 {
+ t.Errorf("usage: got %+v", got.usage)
+ }
+}
+
func TestHermesClientHandleToolCallComplete(t *testing.T) {
t.Parallel()
diff --git a/server/pkg/agent/kiro.go b/server/pkg/agent/kiro.go
new file mode 100644
index 0000000000..e8e7197182
--- /dev/null
+++ b/server/pkg/agent/kiro.go
@@ -0,0 +1,352 @@
+package agent
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// kiroBlockedArgs are flags hardcoded by the daemon that must not be
+// overridden by user-configured custom_args. `acp` is the protocol subcommand,
+// and --trust-all-tools covers Kiro's CLI-level tool gate while
+// hermesClient handles ACP session/request_permission auto-approval. In Kiro
+// CLI 2.1.1, `-a` is short for --trust-all-tools, not --agent; --agent remains
+// allowed so users can select a custom Kiro agent.
+var kiroBlockedArgs = map[string]blockedArgMode{
+ "acp": blockedStandalone,
+ "-a": blockedStandalone,
+ "--trust-all-tools": blockedStandalone,
+ "--trust-tools": blockedWithValue,
+}
+
+// kiroBackend implements Backend by spawning `kiro-cli acp` and communicating
+// via the standard ACP JSON-RPC 2.0 transport over stdin/stdout.
+//
+// Kiro CLI advertises loadSession, returns models from session/new, and supports
+// session/set_model, so the existing Hermes/Kimi ACP client can drive it with
+// only provider-specific launch and tool-name normalization.
+type kiroBackend struct {
+ cfg Config
+}
+
+func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
+ execPath := b.cfg.ExecutablePath
+ if execPath == "" {
+ execPath = "kiro-cli"
+ }
+ if _, err := exec.LookPath(execPath); err != nil {
+ return nil, fmt.Errorf("kiro executable not found at %q: %w", execPath, err)
+ }
+
+ timeout := opts.Timeout
+ if timeout == 0 {
+ timeout = 20 * time.Minute
+ }
+ runCtx, cancel := context.WithTimeout(ctx, timeout)
+
+ kiroArgs := append([]string{"acp", "--trust-all-tools"}, filterCustomArgs(opts.CustomArgs, kiroBlockedArgs, b.cfg.Logger)...)
+ cmd := exec.CommandContext(runCtx, execPath, kiroArgs...)
+ hideAgentWindow(cmd)
+ b.cfg.Logger.Info("agent command", "exec", execPath, "args", kiroArgs)
+ if opts.Cwd != "" {
+ cmd.Dir = opts.Cwd
+ }
+ cmd.Env = buildEnv(b.cfg.Env)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("kiro stdout pipe: %w", err)
+ }
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("kiro stdin pipe: %w", err)
+ }
+ providerErr := newACPProviderErrorSniffer("kiro")
+ cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[kiro:stderr] "), providerErr)
+
+ if err := cmd.Start(); err != nil {
+ cancel()
+ return nil, fmt.Errorf("start kiro: %w", err)
+ }
+
+ b.cfg.Logger.Info("kiro acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
+
+ msgCh := make(chan Message, 256)
+ resCh := make(chan Result, 1)
+
+ var outputMu sync.Mutex
+ var output strings.Builder
+ var streamingCurrentTurn atomic.Bool
+
+ promptDone := make(chan hermesPromptResult, 1)
+
+ c := &hermesClient{
+ cfg: b.cfg,
+ stdin: stdin,
+ pending: make(map[int]*pendingRPC),
+ pendingTools: make(map[string]*pendingToolCall),
+ acceptNotification: func(string) bool {
+ return streamingCurrentTurn.Load()
+ },
+ onMessage: func(msg Message) {
+ if !streamingCurrentTurn.Load() {
+ return
+ }
+ if msg.Type == MessageToolUse {
+ msg.Tool = kiroToolNameFromTitle(msg.Tool)
+ }
+ if msg.Type == MessageText {
+ outputMu.Lock()
+ output.WriteString(msg.Content)
+ outputMu.Unlock()
+ }
+ trySend(msgCh, msg)
+ },
+ onPromptDone: func(result hermesPromptResult) {
+ if !streamingCurrentTurn.Load() {
+ return
+ }
+ select {
+ case promptDone <- result:
+ default:
+ }
+ },
+ }
+
+ readerDone := make(chan struct{})
+ go func() {
+ defer close(readerDone)
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ c.handleLine(line)
+ }
+ c.closeAllPending(fmt.Errorf("kiro process exited"))
+ }()
+
+ go func() {
+ defer cancel()
+ defer close(msgCh)
+ defer close(resCh)
+ defer func() {
+ stdin.Close()
+ _ = cmd.Wait()
+ }()
+
+ startTime := time.Now()
+ finalStatus := "completed"
+ var finalError string
+ var sessionID string
+
+ _, err := c.request(runCtx, "initialize", map[string]any{
+ "protocolVersion": 1,
+ "clientInfo": map[string]any{
+ "name": "multica-agent-sdk",
+ "version": "0.2.0",
+ },
+ "clientCapabilities": map[string]any{},
+ })
+ if err != nil {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kiro initialize failed: %v", err)
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+
+ cwd := opts.Cwd
+ if cwd == "" {
+ cwd = "."
+ }
+
+ if opts.ResumeSessionID != "" {
+ _, err := c.request(runCtx, "session/load", map[string]any{
+ "cwd": cwd,
+ "sessionId": opts.ResumeSessionID,
+ "mcpServers": []any{},
+ })
+ if err != nil {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kiro session/load failed: %v", err)
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+ sessionID = opts.ResumeSessionID
+ } else {
+ result, err := c.request(runCtx, "session/new", map[string]any{
+ "cwd": cwd,
+ "mcpServers": []any{},
+ })
+ if err != nil {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kiro session/new failed: %v", err)
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+ sessionID = extractACPSessionID(result)
+ if sessionID == "" {
+ finalStatus = "failed"
+ finalError = "kiro session/new returned no session ID"
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+ }
+
+ c.sessionID = sessionID
+ b.cfg.Logger.Info("kiro session created", "session_id", sessionID)
+
+ if opts.Model != "" {
+ if _, err := c.request(runCtx, "session/set_model", map[string]any{
+ "sessionId": sessionID,
+ "modelId": opts.Model,
+ }); err != nil {
+ b.cfg.Logger.Warn("kiro set_session_model failed", "error", err, "requested_model", opts.Model)
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kiro could not switch to model %q: %v", opts.Model, err)
+ resCh <- Result{
+ Status: finalStatus,
+ Error: finalError,
+ DurationMs: time.Since(startTime).Milliseconds(),
+ SessionID: sessionID,
+ }
+ return
+ }
+ b.cfg.Logger.Info("kiro session model set", "model", opts.Model)
+ }
+
+ userText := prompt
+ if opts.SystemPrompt != "" {
+ userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
+ }
+
+ promptBlocks := []map[string]any{
+ {"type": "text", "text": userText},
+ }
+ // Kiro's published docs use `content`, while Kiro CLI 2.1.1 still
+ // requires the standard ACP `prompt` field. Send both so either wire
+ // shape can drive the turn.
+ // TODO: drop one field once Kiro lands on a single canonical payload.
+ streamingCurrentTurn.Store(true)
+ _, err = c.request(runCtx, "session/prompt", map[string]any{
+ "sessionId": sessionID,
+ "content": promptBlocks,
+ "prompt": promptBlocks,
+ })
+ if err != nil {
+ if runCtx.Err() == context.DeadlineExceeded {
+ finalStatus = "timeout"
+ finalError = fmt.Sprintf("kiro timed out after %s", timeout)
+ } else if runCtx.Err() == context.Canceled {
+ finalStatus = "aborted"
+ finalError = "execution cancelled"
+ } else {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kiro session/prompt failed: %v", err)
+ }
+ } else {
+ select {
+ case pr := <-promptDone:
+ if pr.stopReason == "cancelled" {
+ finalStatus = "aborted"
+ finalError = "kiro cancelled the prompt"
+ }
+ c.usageMu.Lock()
+ c.usage.InputTokens += pr.usage.InputTokens
+ c.usage.OutputTokens += pr.usage.OutputTokens
+ c.usageMu.Unlock()
+ default:
+ }
+ }
+
+ duration := time.Since(startTime)
+ b.cfg.Logger.Info("kiro finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
+
+ stdin.Close()
+ cancel()
+
+ <-readerDone
+
+ outputMu.Lock()
+ finalOutput := output.String()
+ outputMu.Unlock()
+
+ if finalStatus == "completed" && finalOutput == "" {
+ if msg := providerErr.message(); msg != "" {
+ finalStatus = "failed"
+ finalError = msg
+ }
+ }
+
+ c.usageMu.Lock()
+ u := c.usage
+ c.usageMu.Unlock()
+
+ var usageMap map[string]TokenUsage
+ if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 {
+ model := opts.Model
+ if model == "" {
+ model = "unknown"
+ }
+ usageMap = map[string]TokenUsage{model: u}
+ }
+
+ resCh <- Result{
+ Status: finalStatus,
+ Output: finalOutput,
+ Error: finalError,
+ DurationMs: duration.Milliseconds(),
+ SessionID: sessionID,
+ Usage: usageMap,
+ }
+ }()
+
+ return &Session{Messages: msgCh, Result: resCh}, nil
+}
+
+func kiroToolNameFromTitle(title string) string {
+ t := strings.TrimSpace(title)
+ if t == "" {
+ return ""
+ }
+
+ if idx := strings.Index(t, ":"); idx > 0 {
+ t = strings.TrimSpace(t[:idx])
+ }
+
+ lower := strings.ToLower(t)
+ switch lower {
+ case "read", "read file":
+ return "read_file"
+ case "write", "write file":
+ return "write_file"
+ case "edit", "patch":
+ return "edit_file"
+ case "shell", "bash", "terminal", "run command", "run shell command":
+ return "terminal"
+ case "grep", "search", "find":
+ return "search_files"
+ case "glob":
+ return "glob"
+ case "code":
+ return "code"
+ case "web search":
+ return "web_search"
+ case "fetch", "web fetch":
+ return "web_fetch"
+ case "todo", "todo write", "todo list", "todo_list":
+ return "todo_write"
+ }
+
+ return strings.ReplaceAll(lower, " ", "_")
+}
diff --git a/server/pkg/agent/kiro_test.go b/server/pkg/agent/kiro_test.go
new file mode 100644
index 0000000000..ec7a77b784
--- /dev/null
+++ b/server/pkg/agent/kiro_test.go
@@ -0,0 +1,317 @@
+package agent
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewReturnsKiroBackend(t *testing.T) {
+ t.Parallel()
+ b, err := New("kiro", Config{ExecutablePath: "/nonexistent/kiro-cli"})
+ if err != nil {
+ t.Fatalf("New(kiro) error: %v", err)
+ }
+ if _, ok := b.(*kiroBackend); !ok {
+ t.Fatalf("expected *kiroBackend, got %T", b)
+ }
+}
+
+func TestKiroToolNameFromTitle(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ title string
+ want string
+ }{
+ {"Read file: /tmp/foo.go", "read_file"},
+ {"Write: /tmp/bar.go", "write_file"},
+ {"Patch: /tmp/x", "edit_file"},
+ {"Shell: ls -la", "terminal"},
+ {"Run command: pwd", "terminal"},
+ {"grep", "search_files"},
+ {"Glob: *.go", "glob"},
+ {"Code", "code"},
+ {"Todo List", "todo_write"},
+ {"Custom Thing", "custom_thing"},
+ {"", ""},
+ }
+ for _, tt := range tests {
+ got := kiroToolNameFromTitle(tt.title)
+ if got != tt.want {
+ t.Errorf("kiroToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want)
+ }
+ }
+}
+
+func fakeKiroACPScript() string {
+ return `#!/bin/sh
+if [ -n "$KIRO_ARGS_FILE" ]; then
+ for arg in "$@"; do
+ printf '%s\n' "$arg" >> "$KIRO_ARGS_FILE"
+ done
+fi
+while IFS= read -r line; do
+ if [ -n "$KIRO_REQUESTS_FILE" ]; then
+ printf '%s\n' "$line" >> "$KIRO_REQUESTS_FILE"
+ fi
+ id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
+ case "$line" in
+ *'"method":"initialize"'*)
+ printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id"
+ ;;
+ *'"method":"session/new"'*)
+ printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"currentModelId":"auto","availableModels":[{"modelId":"auto","name":"auto"}]}}}\n' "$id"
+ ;;
+ *'"method":"session/load"'*)
+ printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"history should be ignored"}}}}\n'
+ printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"UsageUpdate","usage":{"inputTokens":1000,"outputTokens":1000,"cachedReadTokens":100}}}}\n'
+ printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCall","toolCallId":"tc-current","name":"Shell","status":"pending","parameters":{"command":"echo replay"}}}}\n'
+ printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
+ ;;
+ *'"method":"session/resume"'*)
+ printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"session/resume should not be used for kiro"}}\n' "$id"
+ ;;
+ *'"method":"session/set_model"'*)
+ case "$line" in
+ *bogus-model*)
+ printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id"
+ exit 0
+ ;;
+ *)
+ printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id"
+ ;;
+ esac
+ ;;
+ *'"method":"session/prompt"'*)
+ case "$line" in
+ *'"content":'*)
+ ;;
+ *)
+ printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id"
+ exit 0
+ ;;
+ esac
+ case "$line" in
+ *'"prompt":'*)
+ ;;
+ *)
+ printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id"
+ exit 0
+ ;;
+ esac
+ printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCallUpdate","toolCallId":"tc-current","status":"completed","name":"Shell","parameters":{"command":"echo current"},"output":"current tool output\\n"}}}\n'
+ printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"loaded"}}}}\n'
+ printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":1}}}\n' "$id"
+ exit 0
+ ;;
+ esac
+done
+`
+}
+
+func TestKiroBackendSetModelFailureFailsTask(t *testing.T) {
+ t.Parallel()
+
+ fakePath := filepath.Join(t.TempDir(), "kiro-cli")
+ writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript()))
+
+ backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()})
+ if err != nil {
+ t.Fatalf("new kiro backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Model: "bogus-model",
+ Timeout: 5 * time.Second,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+
+ select {
+ case result, ok := <-session.Result:
+ if !ok {
+ t.Fatal("result channel closed without a value")
+ }
+ if result.Status != "failed" {
+ t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
+ }
+ if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) {
+ t.Errorf("expected error to name the requested model, got %q", result.Error)
+ }
+ if !strings.Contains(result.Error, "model not available") {
+ t.Errorf("expected error to surface upstream message, got %q", result.Error)
+ }
+ if result.SessionID != "ses_new" {
+ t.Errorf("expected session id to be preserved on failure, got %q", result.SessionID)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("timeout waiting for result")
+ }
+}
+
+func TestKiroBackendInvokesACPWithTrustAllTools(t *testing.T) {
+ t.Parallel()
+
+ tempDir := t.TempDir()
+ argsFile := filepath.Join(tempDir, "argv.txt")
+ fakePath := filepath.Join(tempDir, "kiro-cli")
+ writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript()))
+
+ backend, err := New("kiro", Config{
+ ExecutablePath: fakePath,
+ Logger: slog.Default(),
+ Env: map[string]string{"KIRO_ARGS_FILE": argsFile},
+ })
+ if err != nil {
+ t.Fatalf("new kiro backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Model: "bogus-model",
+ Timeout: 5 * time.Second,
+ CustomArgs: []string{"acp", "--trust-tools", "shell", "-a", "--agent", "multica"},
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+ <-session.Result
+
+ raw, err := os.ReadFile(argsFile)
+ if err != nil {
+ t.Fatalf("read args file: %v", err)
+ }
+ lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
+ wantPrefix := []string{"acp", "--trust-all-tools"}
+ if len(lines) < len(wantPrefix) {
+ t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines)
+ }
+ for i, want := range wantPrefix {
+ if lines[i] != want {
+ t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines)
+ }
+ }
+ for _, blocked := range []string{"--trust-tools", "shell", "-a"} {
+ for _, got := range lines {
+ if got == blocked {
+ t.Errorf("protocol-critical custom arg %q was not filtered: %q", blocked, lines)
+ }
+ }
+ }
+ if strings.Join(lines, "\n") != strings.Join([]string{"acp", "--trust-all-tools", "--agent", "multica"}, "\n") {
+ t.Errorf("unexpected argv after filtering: %q", lines)
+ }
+}
+
+func TestKiroBackendUsesSessionLoadForResume(t *testing.T) {
+ t.Parallel()
+
+ tempDir := t.TempDir()
+ requestsFile := filepath.Join(tempDir, "requests.jsonl")
+ fakePath := filepath.Join(tempDir, "kiro-cli")
+ writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript()))
+
+ backend, err := New("kiro", Config{
+ ExecutablePath: fakePath,
+ Logger: slog.Default(),
+ Env: map[string]string{"KIRO_REQUESTS_FILE": requestsFile},
+ })
+ if err != nil {
+ t.Fatalf("new kiro backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ session, err := backend.Execute(ctx, "continue", ExecOptions{
+ ResumeSessionID: "ses_existing",
+ Timeout: 5 * time.Second,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ var messages []Message
+ messagesDone := make(chan struct{})
+ go func() {
+ defer close(messagesDone)
+ for msg := range session.Messages {
+ messages = append(messages, msg)
+ }
+ }()
+
+ result := <-session.Result
+ <-messagesDone
+ if result.Status != "completed" {
+ t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error)
+ }
+ if result.Output != "loaded" {
+ t.Fatalf("output = %q, want loaded", result.Output)
+ }
+ if usage := result.Usage["unknown"]; usage.InputTokens != 2 || usage.OutputTokens != 1 || usage.CacheReadTokens != 0 {
+ t.Fatalf("usage = %+v, want input=2 output=1 cache_read=0", usage)
+ }
+ if len(messages) != 3 {
+ t.Fatalf("messages = %+v, want current tool use, tool result, and text only", messages)
+ }
+ if messages[0].Type != MessageToolUse {
+ t.Fatalf("messages[0].Type = %v, want MessageToolUse", messages[0].Type)
+ }
+ if messages[0].Tool != "terminal" {
+ t.Fatalf("messages[0].Tool = %q, want terminal", messages[0].Tool)
+ }
+ if command, _ := messages[0].Input["command"].(string); command != "echo current" {
+ t.Fatalf("messages[0].Input[command] = %q, want echo current", command)
+ }
+ if messages[1].Type != MessageToolResult {
+ t.Fatalf("messages[1].Type = %v, want MessageToolResult", messages[1].Type)
+ }
+ if messages[1].Output != "current tool output\n" {
+ t.Fatalf("messages[1].Output = %q, want current tool output", messages[1].Output)
+ }
+ if messages[2].Type != MessageText || messages[2].Content != "loaded" {
+ t.Fatalf("messages[2] = %+v, want text loaded", messages[2])
+ }
+ if result.SessionID != "ses_existing" {
+ t.Fatalf("session id = %q, want ses_existing", result.SessionID)
+ }
+
+ raw, err := os.ReadFile(requestsFile)
+ if err != nil {
+ t.Fatalf("read requests file: %v", err)
+ }
+ requests := string(raw)
+ if !strings.Contains(requests, `"method":"session/load"`) {
+ t.Fatalf("expected session/load request, got:\n%s", requests)
+ }
+ if strings.Contains(requests, `"method":"session/resume"`) {
+ t.Fatalf("kiro backend must not call session/resume, got:\n%s", requests)
+ }
+ if !strings.Contains(requests, `"mcpServers":[]`) {
+ t.Fatalf("session/load must include mcpServers, got:\n%s", requests)
+ }
+ // Kiro docs use content, but Kiro CLI 2.1.1 still requires prompt.
+ if !strings.Contains(requests, `"content":[`) {
+ t.Fatalf("session/prompt must send Kiro content field, got:\n%s", requests)
+ }
+ if !strings.Contains(requests, `"prompt":[`) {
+ t.Fatalf("session/prompt must send standard ACP prompt field for Kiro 2.1.1 compatibility, got:\n%s", requests)
+ }
+}
diff --git a/server/pkg/agent/models.go b/server/pkg/agent/models.go
index 0a4b8857bf..9d5b80516e 100644
--- a/server/pkg/agent/models.go
+++ b/server/pkg/agent/models.go
@@ -75,6 +75,10 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
return cachedDiscovery(providerType, func() ([]Model, error) {
return discoverKimiModels(ctx, executablePath)
})
+ case "kiro":
+ return cachedDiscovery(providerType, func() ([]Model, error) {
+ return discoverKiroModels(ctx, executablePath)
+ })
case "opencode":
return cachedDiscovery(providerType, func() ([]Model, error) {
return discoverOpenCodeModels(ctx, executablePath)
@@ -341,10 +345,10 @@ func parsePiModels(output string) []Model {
// creatable manual-entry input instead of blocking the form.
func discoverHermesModels(ctx context.Context, executablePath string) ([]Model, error) {
return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
- defaultBin: "hermes",
- clientName: "multica-model-discovery",
- extraEnv: []string{"HERMES_YOLO_MODE=1"},
- tmpdirPrefix: "multica-hermes-discovery-",
+ defaultBin: "hermes",
+ clientName: "multica-model-discovery",
+ extraEnv: []string{"HERMES_YOLO_MODE=1"},
+ tmpdirPrefix: "multica-hermes-discovery-",
})
}
@@ -364,6 +368,16 @@ func discoverKimiModels(ctx context.Context, executablePath string) ([]Model, er
})
}
+// discoverKiroModels spins up a throwaway `kiro-cli acp` process and parses
+// the models block Kiro returns from session/new.
+func discoverKiroModels(ctx context.Context, executablePath string) ([]Model, error) {
+ return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
+ defaultBin: "kiro-cli",
+ clientName: "multica-model-discovery",
+ tmpdirPrefix: "multica-kiro-discovery-",
+ })
+}
+
// acpDiscoveryProvider configures how discoverACPModels launches an
// ACP-speaking agent CLI. The shared helper drives every CLI in
// the same way (initialize → session/new → parse models block) — the
diff --git a/server/pkg/agent/models_test.go b/server/pkg/agent/models_test.go
index ac397183e0..78d32c4bb7 100644
--- a/server/pkg/agent/models_test.go
+++ b/server/pkg/agent/models_test.go
@@ -78,6 +78,21 @@ func TestListModelsHermesWithoutBinary(t *testing.T) {
}
}
+func TestListModelsKiroWithoutBinary(t *testing.T) {
+ ctx := context.Background()
+ modelCacheMu.Lock()
+ delete(modelCache, "kiro")
+ modelCacheMu.Unlock()
+
+ got, err := ListModels(ctx, "kiro", "/nonexistent/kiro-cli")
+ if err != nil {
+ t.Fatalf("ListModels(kiro) error: %v", err)
+ }
+ if got == nil {
+ t.Error("expected non-nil slice even when binary is missing")
+ }
+}
+
func TestListModelsUnknownProvider(t *testing.T) {
ctx := context.Background()
_, err := ListModels(ctx, "nonexistent", "")
diff --git a/server/pkg/agent/opencode.go b/server/pkg/agent/opencode.go
index e8887d6f57..e6b27502b4 100644
--- a/server/pkg/agent/opencode.go
+++ b/server/pkg/agent/opencode.go
@@ -6,7 +6,10 @@ import (
"encoding/json"
"fmt"
"io"
+ "os"
"os/exec"
+ "path/filepath"
+ "runtime"
"strings"
"time"
)
@@ -28,9 +31,17 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
if execPath == "" {
execPath = "opencode"
}
- if _, err := exec.LookPath(execPath); err != nil {
+ resolved, err := exec.LookPath(execPath)
+ if err != nil {
return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err)
}
+ if runtime.GOOS == "windows" {
+ if native := resolveOpenCodeNativeFromShim(resolved, os.Stat); native != "" {
+ b.cfg.Logger.Info("opencode resolved to native binary to avoid .cmd shim argv truncation", "shim", resolved, "native", native)
+ resolved = native
+ }
+ }
+ execPath = resolved
timeout := opts.Timeout
if timeout == 0 {
@@ -275,6 +286,59 @@ func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Messag
*finalError = errMsg
}
+// resolveOpenCodeNativeFromShim returns the path to the native OpenCode
+// executable bundled inside the npm package, given the path to the npm
+// `opencode.cmd` shim that PATH lookup found on Windows. Returns "" if shim
+// doesn't end in `.cmd` or no candidate npm platform package has a bundled
+// native binary present.
+//
+// Windows batch argument forwarding via `%*` does not preserve newlines, so
+// multi-line positional argv is truncated at the first newline before the
+// shim hands off to the JS entrypoint. Daemon prompts can include literal
+// newlines (system prompt + user message), which makes the agent see only
+// the first line. Native binary spawn skips the cmd.exe layer entirely.
+//
+// Layout when installed via `npm install -g opencode-ai`:
+//
+// \opencode.cmd (shim)
+// \node_modules\opencode-ai\node_modules\opencode-windows-{x64,x64-baseline,arm64}\bin\opencode.exe (native)
+//
+// `opencode-windows-x64-baseline` ships for older CPUs without AVX2;
+// `opencode-windows-arm64` ships for Surface / Copilot+ PC hosts.
+// Candidates are tried in GOARCH-preferred order so the most likely match
+// for the current host comes first.
+//
+// statFn is injected so this is testable on non-Windows hosts.
+func resolveOpenCodeNativeFromShim(shimPath string, statFn func(string) (os.FileInfo, error)) string {
+ if !strings.EqualFold(filepath.Ext(shimPath), ".cmd") {
+ return ""
+ }
+ prefix := filepath.Dir(shimPath)
+ for _, pkg := range opencodeWindowsPackageCandidates(runtime.GOARCH) {
+ candidate := filepath.Join(prefix, "node_modules", "opencode-ai", "node_modules", pkg, "bin", "opencode.exe")
+ if _, err := statFn(candidate); err == nil {
+ return candidate
+ }
+ }
+ return ""
+}
+
+// opencodeWindowsPackageCandidates returns the npm platform package names
+// that may host the bundled `opencode.exe` on Windows, ordered so the most
+// likely match for the given GOARCH comes first. ARM64 hosts try the arm64
+// build first; everything else tries x64, then the baseline x64 build for
+// older CPUs without AVX2, then arm64 as a final fallback. Cost is one
+// extra statFn call per miss when the GOARCH-preferred package isn't
+// installed.
+func opencodeWindowsPackageCandidates(goarch string) []string {
+ switch goarch {
+ case "arm64":
+ return []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"}
+ default:
+ return []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"}
+ }
+}
+
// extractToolOutput converts the tool state output (which may be a string or
// structured object) into a string.
func extractToolOutput(output any) string {
@@ -328,8 +392,8 @@ type opencodeEventPart struct {
// opencodeTokens represents token usage in a step_finish event.
type opencodeTokens struct {
- Input int64 `json:"input"`
- Output int64 `json:"output"`
+ Input int64 `json:"input"`
+ Output int64 `json:"output"`
Cache *opencodeCacheTokens `json:"cache,omitempty"`
}
diff --git a/server/pkg/agent/opencode_test.go b/server/pkg/agent/opencode_test.go
index 73c6d491cf..8ecb101066 100644
--- a/server/pkg/agent/opencode_test.go
+++ b/server/pkg/agent/opencode_test.go
@@ -2,8 +2,11 @@ package agent
import (
"encoding/json"
+ "errors"
"fmt"
"log/slog"
+ "os"
+ "path/filepath"
"strings"
"testing"
)
@@ -709,3 +712,133 @@ func TestOpencodeProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
close(ch)
}
+
+// ── Windows native-binary resolution tests ──
+
+// fakeStat returns a statFn that reports any path in `present` as existing
+// and every other path as not-found. The returned os.FileInfo is a stub
+// because resolveOpenCodeNativeFromShim only inspects the error.
+func fakeStat(present ...string) func(string) (os.FileInfo, error) {
+ set := make(map[string]struct{}, len(present))
+ for _, p := range present {
+ set[p] = struct{}{}
+ }
+ return func(path string) (os.FileInfo, error) {
+ if _, ok := set[path]; ok {
+ return nil, nil
+ }
+ return nil, errors.New("not found")
+ }
+}
+
+func TestResolveOpenCodeNativeFromShimResolvesNpmShim(t *testing.T) {
+ t.Parallel()
+
+ // Reporter's exact layout from multica#1717.
+ shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
+ native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
+
+ got := resolveOpenCodeNativeFromShim(shim, fakeStat(native))
+ if got != native {
+ t.Errorf("got %q, want %q", got, native)
+ }
+}
+
+func TestResolveOpenCodeNativeFromShimReturnsEmptyWhenNativeMissing(t *testing.T) {
+ t.Parallel()
+
+ // Shim ends in .cmd but the bundled native binary isn't present (e.g.
+ // platform package didn't install or layout changed). Caller must keep
+ // the original shim path so PATH lookup still wins.
+ shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
+
+ got := resolveOpenCodeNativeFromShim(shim, fakeStat())
+ if got != "" {
+ t.Errorf("got %q, want empty (missing native binary)", got)
+ }
+}
+
+func TestResolveOpenCodeNativeFromShimSkipsNonCmdPath(t *testing.T) {
+ t.Parallel()
+
+ // On macOS/Linux the path returned by exec.LookPath is the native
+ // binary itself, with no .cmd extension. Helper should signal "no
+ // rewrite needed" by returning empty.
+ cases := []string{
+ "/usr/local/bin/opencode",
+ "C:\\nvm4w\\nodejs\\opencode.exe",
+ "",
+ }
+ for _, p := range cases {
+ if got := resolveOpenCodeNativeFromShim(p, fakeStat("anything")); got != "" {
+ t.Errorf("path %q: got %q, want empty", p, got)
+ }
+ }
+}
+
+func TestResolveOpenCodeNativeFromShimAcceptsUppercaseExtension(t *testing.T) {
+ t.Parallel()
+
+ // Windows is case-insensitive on filesystem extensions. PATHEXT tokens
+ // are commonly uppercase, and exec.LookPath can return either case.
+ shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.CMD")
+ native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
+
+ got := resolveOpenCodeNativeFromShim(shim, fakeStat(native))
+ if got != native {
+ t.Errorf("got %q, want %q", got, native)
+ }
+}
+
+func TestResolveOpenCodeNativeFromShimFallsBackToBaseline(t *testing.T) {
+ t.Parallel()
+
+ // Older CPUs without AVX2 get `opencode-windows-x64-baseline` instead of
+ // the default x64 build. Resolver should fall through and find it when
+ // the primary x64 package isn't installed.
+ shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
+ baseline := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe")
+
+ got := resolveOpenCodeNativeFromShim(shim, fakeStat(baseline))
+ if got != baseline {
+ t.Errorf("got %q, want %q", got, baseline)
+ }
+}
+
+func TestOpencodeWindowsPackageCandidatesArm64(t *testing.T) {
+ t.Parallel()
+
+ // ARM64 hosts (Surface, Copilot+ PC) should try arm64 first so the
+ // resolver doesn't accidentally pick up a leftover x64 install when
+ // the matching arm64 package is present.
+ got := opencodeWindowsPackageCandidates("arm64")
+ want := []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"}
+ if !equalStringSlice(got, want) {
+ t.Errorf("got %v, want %v", got, want)
+ }
+}
+
+func TestOpencodeWindowsPackageCandidatesAmd64(t *testing.T) {
+ t.Parallel()
+
+ // amd64 (and any non-arm64) hosts try x64 → baseline → arm64. The arm64
+ // fallback at the end covers the unusual case where only the arm64
+ // package is installed; resolution still succeeds.
+ got := opencodeWindowsPackageCandidates("amd64")
+ want := []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"}
+ if !equalStringSlice(got, want) {
+ t.Errorf("got %v, want %v", got, want)
+ }
+}
+
+func equalStringSlice(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go
index e93276111a..868b36a7f3 100644
--- a/server/pkg/db/generated/agent.sql.go
+++ b/server/pkg/db/generated/agent.sql.go
@@ -261,6 +261,62 @@ func (q *Queries) CancelAgentTasksByIssueAndAgent(ctx context.Context, arg Cance
return items, nil
}
+const cancelAgentTasksByTriggerComment = `-- name: CancelAgentTasksByTriggerComment :many
+UPDATE agent_task_queue
+SET status = 'cancelled', completed_at = now()
+WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running')
+RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at
+`
+
+// Cancels active tasks whose trigger is the given comment. Called when a
+// comment is deleted so the agent does not run with the now-deleted content
+// already embedded in its prompt. Must run BEFORE the comment row is deleted
+// because the FK ON DELETE SET NULL would otherwise nullify trigger_comment_id
+// and we'd lose the ability to find the affected tasks.
+func (q *Queries) CancelAgentTasksByTriggerComment(ctx context.Context, triggerCommentID pgtype.UUID) ([]AgentTaskQueue, error) {
+ rows, err := q.db.Query(ctx, cancelAgentTasksByTriggerComment, triggerCommentID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []AgentTaskQueue{}
+ for rows.Next() {
+ var i AgentTaskQueue
+ if err := rows.Scan(
+ &i.ID,
+ &i.AgentID,
+ &i.IssueID,
+ &i.Status,
+ &i.Priority,
+ &i.DispatchedAt,
+ &i.StartedAt,
+ &i.CompletedAt,
+ &i.Result,
+ &i.Error,
+ &i.CreatedAt,
+ &i.Context,
+ &i.RuntimeID,
+ &i.SessionID,
+ &i.WorkDir,
+ &i.TriggerCommentID,
+ &i.ChatSessionID,
+ &i.AutopilotRunID,
+ &i.Attempt,
+ &i.MaxAttempts,
+ &i.ParentTaskID,
+ &i.FailureReason,
+ &i.LastHeartbeatAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const claimAgentTask = `-- name: ClaimAgentTask :one
UPDATE agent_task_queue
SET status = 'dispatched', dispatched_at = now()
@@ -1444,6 +1500,46 @@ func (q *Queries) RecoverOrphanedTasksForRuntime(ctx context.Context, runtimeID
return items, nil
}
+const refreshAgentStatusFromTasks = `-- name: RefreshAgentStatusFromTasks :one
+UPDATE agent AS a
+SET status = CASE WHEN EXISTS (
+ SELECT 1 FROM agent_task_queue q
+ WHERE q.agent_id = a.id AND q.status IN ('dispatched', 'running')
+) THEN 'working' ELSE 'idle' END,
+ updated_at = now()
+WHERE a.id = $1
+RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model
+`
+
+func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUID) (Agent, error) {
+ row := q.db.QueryRow(ctx, refreshAgentStatusFromTasks, id)
+ var i Agent
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.AvatarUrl,
+ &i.RuntimeMode,
+ &i.RuntimeConfig,
+ &i.Visibility,
+ &i.Status,
+ &i.MaxConcurrentTasks,
+ &i.OwnerID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Description,
+ &i.RuntimeID,
+ &i.Instructions,
+ &i.ArchivedAt,
+ &i.ArchivedBy,
+ &i.CustomEnv,
+ &i.CustomArgs,
+ &i.McpConfig,
+ &i.Model,
+ )
+ return i, err
+}
+
const restoreAgent = `-- name: RestoreAgent :one
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
WHERE id = $1
diff --git a/server/pkg/db/generated/issue_label.sql.go b/server/pkg/db/generated/issue_label.sql.go
new file mode 100644
index 0000000000..54134cf9c3
--- /dev/null
+++ b/server/pkg/db/generated/issue_label.sql.go
@@ -0,0 +1,302 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: issue_label.sql
+
+package db
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const attachLabelToIssue = `-- name: AttachLabelToIssue :exec
+INSERT INTO issue_to_label (issue_id, label_id)
+SELECT $1::uuid, $2::uuid
+WHERE EXISTS (
+ SELECT 1 FROM issue i
+ WHERE i.id = $1::uuid
+ AND i.workspace_id = $3::uuid
+)
+AND EXISTS (
+ SELECT 1 FROM issue_label l
+ WHERE l.id = $2::uuid
+ AND l.workspace_id = $3::uuid
+)
+ON CONFLICT DO NOTHING
+`
+
+type AttachLabelToIssueParams struct {
+ IssueID pgtype.UUID `json:"issue_id"`
+ LabelID pgtype.UUID `json:"label_id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+// Workspace-guarded INSERT: the WHERE EXISTS clauses ensure both the issue
+// and the label belong to the given workspace. A future caller that forgets
+// handler-level prechecks still cannot attach labels across workspaces.
+func (q *Queries) AttachLabelToIssue(ctx context.Context, arg AttachLabelToIssueParams) error {
+ _, err := q.db.Exec(ctx, attachLabelToIssue, arg.IssueID, arg.LabelID, arg.WorkspaceID)
+ return err
+}
+
+const createLabel = `-- name: CreateLabel :one
+INSERT INTO issue_label (workspace_id, name, color)
+VALUES ($1, $2, $3)
+RETURNING id, workspace_id, name, color, created_at, updated_at
+`
+
+type CreateLabelParams struct {
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+func (q *Queries) CreateLabel(ctx context.Context, arg CreateLabelParams) (IssueLabel, error) {
+ row := q.db.QueryRow(ctx, createLabel, arg.WorkspaceID, arg.Name, arg.Color)
+ var i IssueLabel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.Color,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const deleteLabel = `-- name: DeleteLabel :one
+DELETE FROM issue_label
+WHERE id = $1 AND workspace_id = $2
+RETURNING id
+`
+
+type DeleteLabelParams struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+// :one RETURNING id so the handler distinguishes pgx.ErrNoRows (→ 404) from
+// infrastructure errors (→ 500), and avoids a TOCTOU precheck.
+func (q *Queries) DeleteLabel(ctx context.Context, arg DeleteLabelParams) (pgtype.UUID, error) {
+ row := q.db.QueryRow(ctx, deleteLabel, arg.ID, arg.WorkspaceID)
+ var id pgtype.UUID
+ err := row.Scan(&id)
+ return id, err
+}
+
+const detachLabelFromIssue = `-- name: DetachLabelFromIssue :exec
+DELETE FROM issue_to_label
+WHERE issue_id = $1::uuid
+ AND label_id = $2::uuid
+ AND EXISTS (
+ SELECT 1 FROM issue i
+ WHERE i.id = $1::uuid
+ AND i.workspace_id = $3::uuid
+ )
+`
+
+type DetachLabelFromIssueParams struct {
+ IssueID pgtype.UUID `json:"issue_id"`
+ LabelID pgtype.UUID `json:"label_id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+// Workspace-guarded DELETE: only deletes if the issue is in the given
+// workspace. Mirror of the attach query.
+func (q *Queries) DetachLabelFromIssue(ctx context.Context, arg DetachLabelFromIssueParams) error {
+ _, err := q.db.Exec(ctx, detachLabelFromIssue, arg.IssueID, arg.LabelID, arg.WorkspaceID)
+ return err
+}
+
+const getLabel = `-- name: GetLabel :one
+SELECT id, workspace_id, name, color, created_at, updated_at FROM issue_label
+WHERE id = $1 AND workspace_id = $2
+`
+
+type GetLabelParams struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetLabel(ctx context.Context, arg GetLabelParams) (IssueLabel, error) {
+ row := q.db.QueryRow(ctx, getLabel, arg.ID, arg.WorkspaceID)
+ var i IssueLabel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.Color,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const listLabels = `-- name: ListLabels :many
+SELECT id, workspace_id, name, color, created_at, updated_at FROM issue_label
+WHERE workspace_id = $1
+ORDER BY LOWER(name) ASC
+`
+
+func (q *Queries) ListLabels(ctx context.Context, workspaceID pgtype.UUID) ([]IssueLabel, error) {
+ rows, err := q.db.Query(ctx, listLabels, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []IssueLabel{}
+ for rows.Next() {
+ var i IssueLabel
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.Color,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listLabelsByIssue = `-- name: ListLabelsByIssue :many
+SELECT l.id, l.workspace_id, l.name, l.color, l.created_at, l.updated_at
+FROM issue_label l
+JOIN issue_to_label il ON il.label_id = l.id
+WHERE il.issue_id = $1::uuid
+ AND l.workspace_id = $2::uuid
+ORDER BY LOWER(l.name) ASC
+`
+
+type ListLabelsByIssueParams struct {
+ IssueID pgtype.UUID `json:"issue_id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+// Workspace filter at the SQL layer (mirrors GetProjectInWorkspace). Any caller
+// that passes the wrong workspace gets an empty list rather than leaking labels.
+func (q *Queries) ListLabelsByIssue(ctx context.Context, arg ListLabelsByIssueParams) ([]IssueLabel, error) {
+ rows, err := q.db.Query(ctx, listLabelsByIssue, arg.IssueID, arg.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []IssueLabel{}
+ for rows.Next() {
+ var i IssueLabel
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.Color,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listLabelsForIssues = `-- name: ListLabelsForIssues :many
+SELECT il.issue_id, l.id, l.workspace_id, l.name, l.color, l.created_at, l.updated_at
+FROM issue_label l
+JOIN issue_to_label il ON il.label_id = l.id
+WHERE il.issue_id = ANY($1::uuid[])
+ AND l.workspace_id = $2::uuid
+ORDER BY il.issue_id, LOWER(l.name) ASC
+`
+
+type ListLabelsForIssuesParams struct {
+ IssueIds []pgtype.UUID `json:"issue_ids"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+type ListLabelsForIssuesRow struct {
+ IssueID pgtype.UUID `json:"issue_id"`
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+// Bulk variant: fetch labels for many issues in one round-trip so the issue
+// list endpoints can fold labels into each row without N+1 queries from the
+// client. Workspace-guarded the same way as ListLabelsByIssue.
+func (q *Queries) ListLabelsForIssues(ctx context.Context, arg ListLabelsForIssuesParams) ([]ListLabelsForIssuesRow, error) {
+ rows, err := q.db.Query(ctx, listLabelsForIssues, arg.IssueIds, arg.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListLabelsForIssuesRow{}
+ for rows.Next() {
+ var i ListLabelsForIssuesRow
+ if err := rows.Scan(
+ &i.IssueID,
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.Color,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateLabel = `-- name: UpdateLabel :one
+UPDATE issue_label SET
+ name = COALESCE($3, name),
+ color = COALESCE($4, color),
+ updated_at = now()
+WHERE id = $1 AND workspace_id = $2
+RETURNING id, workspace_id, name, color, created_at, updated_at
+`
+
+type UpdateLabelParams struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name pgtype.Text `json:"name"`
+ Color pgtype.Text `json:"color"`
+}
+
+func (q *Queries) UpdateLabel(ctx context.Context, arg UpdateLabelParams) (IssueLabel, error) {
+ row := q.db.QueryRow(ctx, updateLabel,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.Name,
+ arg.Color,
+ )
+ var i IssueLabel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.Color,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go
index 3eaebd66a4..45613342af 100644
--- a/server/pkg/db/generated/models.go
+++ b/server/pkg/db/generated/models.go
@@ -279,10 +279,12 @@ type IssueDependency struct {
}
type IssueLabel struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Name string `json:"name"`
- Color string `json:"color"`
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type IssueReaction struct {
diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql
index e9de69fada..cb37db08f7 100644
--- a/server/pkg/db/queries/agent.sql
+++ b/server/pkg/db/queries/agent.sql
@@ -121,6 +121,17 @@ SET status = 'cancelled', completed_at = now()
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running')
RETURNING *;
+-- name: CancelAgentTasksByTriggerComment :many
+-- Cancels active tasks whose trigger is the given comment. Called when a
+-- comment is deleted so the agent does not run with the now-deleted content
+-- already embedded in its prompt. Must run BEFORE the comment row is deleted
+-- because the FK ON DELETE SET NULL would otherwise nullify trigger_comment_id
+-- and we'd lose the ability to find the affected tasks.
+UPDATE agent_task_queue
+SET status = 'cancelled', completed_at = now()
+WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running')
+RETURNING *;
+
-- name: GetAgentTask :one
SELECT * FROM agent_task_queue
WHERE id = $1;
@@ -356,3 +367,13 @@ ORDER BY created_at DESC;
UPDATE agent SET status = $2, updated_at = now()
WHERE id = $1
RETURNING *;
+
+-- name: RefreshAgentStatusFromTasks :one
+UPDATE agent AS a
+SET status = CASE WHEN EXISTS (
+ SELECT 1 FROM agent_task_queue q
+ WHERE q.agent_id = a.id AND q.status IN ('dispatched', 'running')
+) THEN 'working' ELSE 'idle' END,
+ updated_at = now()
+WHERE a.id = $1
+RETURNING *;
diff --git a/server/pkg/db/queries/issue_label.sql b/server/pkg/db/queries/issue_label.sql
new file mode 100644
index 0000000000..0469c02476
--- /dev/null
+++ b/server/pkg/db/queries/issue_label.sql
@@ -0,0 +1,79 @@
+-- name: ListLabels :many
+SELECT * FROM issue_label
+WHERE workspace_id = $1
+ORDER BY LOWER(name) ASC;
+
+-- name: GetLabel :one
+SELECT * FROM issue_label
+WHERE id = $1 AND workspace_id = $2;
+
+-- name: CreateLabel :one
+INSERT INTO issue_label (workspace_id, name, color)
+VALUES ($1, $2, $3)
+RETURNING *;
+
+-- name: UpdateLabel :one
+UPDATE issue_label SET
+ name = COALESCE(sqlc.narg('name'), name),
+ color = COALESCE(sqlc.narg('color'), color),
+ updated_at = now()
+WHERE id = $1 AND workspace_id = $2
+RETURNING *;
+
+-- name: DeleteLabel :one
+-- :one RETURNING id so the handler distinguishes pgx.ErrNoRows (→ 404) from
+-- infrastructure errors (→ 500), and avoids a TOCTOU precheck.
+DELETE FROM issue_label
+WHERE id = $1 AND workspace_id = $2
+RETURNING id;
+
+-- name: AttachLabelToIssue :exec
+-- Workspace-guarded INSERT: the WHERE EXISTS clauses ensure both the issue
+-- and the label belong to the given workspace. A future caller that forgets
+-- handler-level prechecks still cannot attach labels across workspaces.
+INSERT INTO issue_to_label (issue_id, label_id)
+SELECT sqlc.arg('issue_id')::uuid, sqlc.arg('label_id')::uuid
+WHERE EXISTS (
+ SELECT 1 FROM issue i
+ WHERE i.id = sqlc.arg('issue_id')::uuid
+ AND i.workspace_id = sqlc.arg('workspace_id')::uuid
+)
+AND EXISTS (
+ SELECT 1 FROM issue_label l
+ WHERE l.id = sqlc.arg('label_id')::uuid
+ AND l.workspace_id = sqlc.arg('workspace_id')::uuid
+)
+ON CONFLICT DO NOTHING;
+
+-- name: DetachLabelFromIssue :exec
+-- Workspace-guarded DELETE: only deletes if the issue is in the given
+-- workspace. Mirror of the attach query.
+DELETE FROM issue_to_label
+WHERE issue_id = sqlc.arg('issue_id')::uuid
+ AND label_id = sqlc.arg('label_id')::uuid
+ AND EXISTS (
+ SELECT 1 FROM issue i
+ WHERE i.id = sqlc.arg('issue_id')::uuid
+ AND i.workspace_id = sqlc.arg('workspace_id')::uuid
+ );
+
+-- name: ListLabelsByIssue :many
+-- Workspace filter at the SQL layer (mirrors GetProjectInWorkspace). Any caller
+-- that passes the wrong workspace gets an empty list rather than leaking labels.
+SELECT l.*
+FROM issue_label l
+JOIN issue_to_label il ON il.label_id = l.id
+WHERE il.issue_id = sqlc.arg('issue_id')::uuid
+ AND l.workspace_id = sqlc.arg('workspace_id')::uuid
+ORDER BY LOWER(l.name) ASC;
+
+-- name: ListLabelsForIssues :many
+-- Bulk variant: fetch labels for many issues in one round-trip so the issue
+-- list endpoints can fold labels into each row without N+1 queries from the
+-- client. Workspace-guarded the same way as ListLabelsByIssue.
+SELECT il.issue_id, l.*
+FROM issue_label l
+JOIN issue_to_label il ON il.label_id = l.id
+WHERE il.issue_id = ANY(sqlc.arg('issue_ids')::uuid[])
+ AND l.workspace_id = sqlc.arg('workspace_id')::uuid
+ORDER BY il.issue_id, LOWER(l.name) ASC;
diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go
index 6dec82eaa1..1ca5d9ee7e 100644
--- a/server/pkg/protocol/events.go
+++ b/server/pkg/protocol/events.go
@@ -11,10 +11,10 @@ const (
EventCommentCreated = "comment:created"
EventCommentUpdated = "comment:updated"
EventCommentDeleted = "comment:deleted"
- EventReactionAdded = "reaction:added"
- EventReactionRemoved = "reaction:removed"
- EventIssueReactionAdded = "issue_reaction:added"
- EventIssueReactionRemoved = "issue_reaction:removed"
+ EventReactionAdded = "reaction:added"
+ EventReactionRemoved = "reaction:removed"
+ EventIssueReactionAdded = "issue_reaction:added"
+ EventIssueReactionRemoved = "issue_reaction:removed"
// Agent events
EventAgentStatus = "agent:status"
@@ -68,6 +68,12 @@ const (
EventProjectUpdated = "project:updated"
EventProjectDeleted = "project:deleted"
+ // Label events
+ EventLabelCreated = "label:created"
+ EventLabelUpdated = "label:updated"
+ EventLabelDeleted = "label:deleted"
+ EventIssueLabelsChanged = "issue_labels:changed"
+
// Pin events
EventPinCreated = "pin:created"
EventPinDeleted = "pin:deleted"
@@ -87,6 +93,7 @@ const (
EventAutopilotRunDone = "autopilot:run_done"
// Daemon events
- EventDaemonHeartbeat = "daemon:heartbeat"
- EventDaemonRegister = "daemon:register"
+ EventDaemonHeartbeat = "daemon:heartbeat"
+ EventDaemonRegister = "daemon:register"
+ EventDaemonTaskAvailable = "daemon:task_available"
)
diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go
index 3e11e8eb80..d98d01456b 100644
--- a/server/pkg/protocol/messages.go
+++ b/server/pkg/protocol/messages.go
@@ -16,6 +16,13 @@ type TaskDispatchPayload struct {
Description string `json:"description"`
}
+// TaskAvailablePayload is sent from server to daemon as a wakeup hint. The
+// daemon still claims work through the existing HTTP claim endpoint.
+type TaskAvailablePayload struct {
+ RuntimeID string `json:"runtime_id"`
+ TaskID string `json:"task_id,omitempty"`
+}
+
// TaskProgressPayload is sent from daemon to server during task execution.
type TaskProgressPayload struct {
TaskID string `json:"task_id"`
@@ -37,10 +44,10 @@ type TaskMessagePayload struct {
IssueID string `json:"issue_id,omitempty"`
Seq int `json:"seq"`
Type string `json:"type"` // "text", "tool_use", "tool_result", "error"
- Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result
- Content string `json:"content,omitempty"` // text content
- Input map[string]any `json:"input,omitempty"` // tool input (tool_use only)
- Output string `json:"output,omitempty"` // tool output (tool_result only)
+ Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result
+ Content string `json:"content,omitempty"` // text content
+ Input map[string]any `json:"input,omitempty"` // tool input (tool_use only)
+ Output string `json:"output,omitempty"` // tool output (tool_result only)
}
// DaemonRegisterPayload is sent from daemon to server on connection.